복사해 붙여넣는 주소 — CBOR과 빈칸이 만든 사고
어떤 프로그램은 연결에 필요한 정보를 문자열 하나로 만들어 준다.
tc<base64url로 인코딩된 40여 바이트>
이걸 상대에게 보내면 상대가 붙여넣고 연결한다. 채팅으로 보내고, 메일에 적고, 터미널에 친다. 사람이 옮겨 적는 값이라 길이가 곧 사용성이다.
이 글은 그 길이를 어떻게 줄이는지, 그리고 줄이려고 고른 방법이 어떤 사고를 만들었는지 정리한다.
안에 들어가는 것
별것 없다. 두 가지다.
- 상대 서버를 알아보는 열쇠(공개키)
- 어느 중계소에서 만날지
이걸 그대로 글자로 쓰면 이렇게 된다.
{
"ServerPublic": "nodekey:<32바이트 공개키>",
"RegionID": 302
}
붙여넣기에는 너무 길다. 줄여야 한다.
CBOR — 같은 내용을 더 작게
CBOR은 위와 같은 내용을 담되 글자가 아니라 바이너리로 쓰는 방식이다. 담는 내용은 같고 적는 법만 다르다.
여기까지도 부족해서 한 걸음 더 간다. 이름을 한 글자로 줄인다.
| 원래 이름 | 줄인 이름 |
|---|---|
ServerPublic | p |
Region | r |
RegionID | i |
ServerPublic은 12글자, p는 1글자다. 항목이 여럿이면 차이가 커진다.
대신 대가가 있다. 이 한 글자가 곧 약속이 된다. 나중에 p를 다른 뜻으로 바꾸면
이미 발급된 주소가 전부 못 읽는 것이 된다. 그래서 코드에 이런 주석이 붙어 있다.
짧은 이름이 곧 형식이다. 바꾸거나 재사용하지 말 것.
주석만 두지 않고 자동 검사까지 붙여뒀다. 누가 이름을 바꾸면 검사가 실패한다.
압축을 위해 고른 구조
중계소는 여러 개일 수 있어서 목록으로 담는다. 이런 모양이다.
중계소 목록 = [ 중계소1, 중계소2, ... ]
여기서 문제가 생긴다. CBOR에는 빈칸을 넣을 수 있다.
중계소 목록 = [ 빈칸 ]
목록에 항목이 하나 있긴 한데 그 안이 비어 있는 상태다. 프로그램은 목록을 하나씩 꺼내 읽는데, 빈칸을 꺼내서 “이 중계소의 번호는?” 하고 물어보면 물어볼 대상이 없다.
그러면 프로그램이 죽는다. 오류를 내는 게 아니라 그냥 멈춘다.
panic: invalid memory address or nil pointer dereference
왜 위험한가
이 주소는 남이 준다. 그게 핵심이다.
게다가 주소 자리에 도메인 이름을 적으면, 프로그램이 그 이름을 조회해서 거기 적힌 값을 주소로 쓴다. 즉 도메인 주인이 원하는 값을 넣을 수 있다.
그리고 부르는 쪽이 막을 방법이 없다. 보통은 이렇게 방어한다.
주소를 읽어본다 → 오류가 나면 무시한다
그런데 프로그램이 죽어버리면 이 방어를 지나간다. 오류를 받는 게 아니라 프로그램 자체가 끝나기 때문이다.
한 번 고쳐진 자리였다
같은 파일이 이미 한 번 손을 탔다. 열쇠 길이가 32바이트가 아니면 죽던 문제를 고친 변경이 있었고, 근거가 **“이 값은 신뢰할 수 없는 곳에서 온다”**였다.
같은 이유, 같은 입구다. 그런데 그 변경은 열쇠의 길이만 확인했다. 목록 안의 빈칸은 보지 않았다.
고치는 방법
꺼내서 쓰기 전에 빈칸인지 확인하면 된다.
for i, wr := range w.Region {
if wr == nil {
return zero, fmt.Errorf("invalid connection blob: region %d is null", i)
}
...
}
한 가지 예외를 뒀다. 주소를 뜯어보여 주는 기능은 그대로 두었다. 깨진 주소가 어떻게 생겼는지 확인하라고 있는 기능이므로, 빈칸을 거부하는 대신 빈칸이 있다고 보여주는 편이 쓸모 있다.
$ 주소 뜯어보기 <빈칸이 든 주소>
{ "ServerPublic": "...", "Region": [빈칸] }
이 의도가 나중에 지워지지 않도록 검사를 하나 걸어두었다. 누가 검사를 더 앞으로 옮기면 그 검사가 실패해서 알려준다.
남이 주는 값을 파싱한다면
이 사고는 압축이 만든 것이 아니다. 압축을 위해 고른 구조가 빈 자리를 허용하는데, 읽는 쪽이 그걸 예상하지 않은 것이 원인이다. 같은 형태는 목록·선택 항목·중첩 구조를 쓰는 어떤 형식에서도 나온다.
읽는 코드를 볼 때 확인할 것.
- 목록의 각 항목이 비어 있을 수 있는가. 목록 자체가 비었는지만 보고 넘어가기 쉽다.
- 길이·범위를 검사한 자리가 있다면, 같은 이유가 닿는 다른 필드도 검사하는가.
- 잘못된 입력에 오류를 내는가, 죽는가. 이 둘은 다르다.
마지막이 핵심이다. 부르는 쪽은 보통 이렇게 방어한다.
읽어본다 → 오류가 나면 무시한다
죽으면 이 방어가 작동하지 않는다. 오류를 받는 게 아니라 프로세스가 끝나기 때문에, 호출부가 아무리 잘 짜여 있어도 소용이 없다. 파서가 죽지 않는 것은 파서의 책임이지 호출부의 책임이 아니다.