50 직렬화 — 기억 속 값을 바이트의 약속으로
먼저 알아야 할 것
돌아보기
48장에서 구조체를 fwrite 한 줄로 내보내면 안 되는 이유 넷을 보았고, 필드를 하나씩 정해진 순서로 적는 처방을 보았다. 처방이 그렇게 간단하다면 왜 직렬화에 한 장을 따로 쓰는가?
답. 처방은 간단하지만 그 처방이 지켜야 하는 약속이 많기 때문이다. 필드 일곱 바이트를 적는 함수는 열 줄이면 되지만, 그 바이트를 주고받는 상대는 모르는 사람이 만든 프로그램이고, 몇 년 뒤의 판이고, 때로는 적의를 품고 입력을 손으로 지어내는 사람이다. 바이트를 어떻게 쓰는가보다 그 바이트가 무엇을 약속하는가가 어렵다. 이 장은 그 약속을 다룬다 — 무엇을 정해 두어야 하고, 그것을 어떤 구조로 지키고, 지키지 못했을 때 어떤 사고가 났는가.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
- 판 번호가 있는데 왜 태그까지 필요한가? 판 번호만 보고 갈라 읽으면 되지 않는가?
- 「보내는 것은 엄격하게, 받는 것은 관대하게」라는 말이 있다. 받는 쪽이 관대해야 하지 않는가?
50.1 직렬화란 무엇인가 — 값을 바깥으로 내보내는 일#
직렬화(serialisation)는 기억 속의 값을 바깥과 합의한 바이트열로 바꾸는 일이다. 그 반대, 바이트열을 읽어 값을 되짓는 일은 역직렬화(deserialisation)라 한다. 「마셜링」(marshalling), 「인코딩」이라는 말도 같은 자리에 쓰인다.
기억 속 값은 이미 바이트로 되어 있다. 그런데도 따로 바꿔야 하는 까닭은, 기억 속 바이트가 이 프로그램, 이 컴파일러, 이 기계만의 약속이기 때문이다. C 표준이 구조체의 모양에 대해 약속하는 것은 많지 않다.
| 약속한다 | 약속하지 않는다 |
|---|---|
| 멤버의 주소는 선언한 순서대로 커진다(C23 6.7.3.2p17) | 멤버 사이에 패딩이 몇 바이트 끼는가 |
| 첫 멤버 앞에는 패딩이 없다(같은 조항) | 패딩 바이트의 값 — 값을 담을 때마다 미지정(6.2.6.1p6) |
| 구조체를 가리키는 포인터는 첫 멤버를 가리킨다 | int·long·포인터의 크기, 바이트 순서 |
표 50.1 — 구조체의 기억 모양에 대해 표준이 약속하는 것과 하지 않는 것
오른쪽 칸이 모두 구현이 정하는 것이다. 그래서 「내 기계의 기억 모양을 그대로 내보낸다」는 구현의 사정을 바깥에 약속하는 일이 된다. 직렬화는 그 약속을 끊는다 — 바깥에 나가는 바이트는 구현이 아니라 형식(format)이 정한다.
50.1.1 바이트열을 받는 상대는 넷이다#
바이트열을 받는 상대를 꼽으면 직렬화가 왜 피할 수 없는지 보인다.
| 상대 | 예 | 나와 다를 수 있는 것 |
|---|---|---|
| 다른 기계 | 네트워크 저편의 서버, 센서, 휴대 전화 | 엔디안, 타입 크기, 정렬, 언어 |
| 다른 프로세스 | 같은 기계의 다른 프로그램 | 컴파일러, 컴파일 옵션, 주소 공간 |
| 다른 판의 나 | 어제 저장한 파일을 오늘의 판이 읽는다 | 구조체에 더해지고 빠진 멤버 |
| 적의를 품은 상대 | 손으로 지어낸 파일, 조작한 패킷 | 모든 것 — 규칙을 지킬 이유가 없다 |
표 50.2 — 바이트열을 받는 상대와 그 상대가 나와 다를 수 있는 점
셋째 줄이 자주 잊힌다. 파일에 저장하는 것도 직렬화다 — 미래의 나와 통신하는 것이기 때문이다. 그리고 넷째 줄은 앞의 셋을 모두 덮는다. 형식을 지키는 상대만 있다면 직렬화는 정확성의 문제이지만, 지키지 않는 상대가 하나라도 있으면 보안의 문제가 된다.
50.1.2 포인터는 바깥으로 나가지 못한다#
48장에서 구조체를 통째로 내보낼 때의 문제를 모은 표에 한 줄을 더해야 한다. 구조체에 포인터가 들어 있으면 통째로 쓰기는 더 나빠진다. 포인터의 값은 이 프로세스의 주소 공간에서만 뜻이 있다. 다른 프로세스에서, 다른 날 다시 띄운 같은 프로그램에서, 그 수는 아무것도 가리키지 않는다(10장).
그래서 직렬화는 참조를 번호로 바꾼다. 「이 노드의 다음 노드는 주소 0x7ffd…」 대신 「이 파일의 3번째 레코드」, 또는 「이 바이트열의 120바이트째」로 적는다. 번호는 바깥에서도 뜻이 있다. 읽는 쪽은 번호를 다시 포인터로 바꾸는데, 그때 그 번호가 범위 안인지 확인하는 것이 읽는 쪽의 일이다. 이 확인을 빠뜨리면 범위 밖 번호를 따라가 끝을 넘어 읽게 된다 — 뒤의 「보안」 절에서 커서가 막는 갈래(「끝을 넘어 읽기」)다.
50.2 직렬화를 피할 수 있는 자리와 그 대가#
직렬화에는 비용이 든다. 값을 하나씩 옮겨 적는 시간과, 형식을 정하고 지키는 사람의 시간이다. 그래서 「피할 수는 없는가」를 묻는 것은 정당하다. 답은 「좁은 자리에서는 피할 수 있고, 피할 때는 그 대가를 알고 피한다」이다.
| 자리 | 어떻게 피하는가 | 대가 |
|---|---|---|
| 한 프로세스 안(스레드 사이) | 값을 그대로 넘긴다 — 직렬화할 일이 없다 | 동기화(제12부) |
| 같은 기계의 프로세스 사이 | 공유 메모리에 기억 모양을 형식으로 굳힌 구조체를 둔다 | 양쪽이 같은 배치를 쓰는지 빌드가 확인해야 한다 |
| 읽기가 대부분인 큰 자료 | 「제자리 형식」: 바이트열을 복사하지 않고 그 자리에서 읽는 접근 함수 | 형식을 바꾸기 어렵다, 읽는 쪽 검증을 건너뛰기 쉽다 |
| 사람이 읽고 고칠 자료 | 텍스트 형식(JSON 등) — 110장 | 크기와 해석 비용 |
표 50.3 — 직렬화를 피하는 길과 그 대가
50.2.1 기억 모양을 형식으로 굳히기#
둘째 줄을 정직하게 하려면, 기억 모양 자체를 형식으로 만들어야 한다. 그 말은 표준이 약속하지 않는 것(이 장 첫 표의 오른쪽 칸)을 모두 내가 확인한다는 뜻이다. 고정 폭 타입만 쓰고, 넓은 멤버부터 놓아 패딩이 끼지 않게 하고, 그 결과를 static_assert 로 못박는다.
examples/serialization/wire.c
/* 직렬화를 정직하게 건너뛰기: 기억 속 배치가 *곧* 형식일 때.
고정 폭 멤버, 넓은 것부터, 숨은 패딩 없음, 오프셋은 static_assert 로 못박는다 ---
그래서 다른 컴파일러나 옵션이면 다른 바이트를 조용히 쓰는 대신 빌드가 멈춘다.
바이트 순서는 여전히 기계의 것이다: 그것도 확인한다. */
#include <stdbit.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
struct wire_header { /* 같은 기계의 두 프로세스가 나눠 쓴다 */
uint64_t sequence;
uint32_t length;
uint16_t kind;
uint8_t flags;
uint8_t version;
};
static_assert(sizeof(struct wire_header) == 16, "layout changed");
static_assert(offsetof(struct wire_header, length) == 8);
static_assert(offsetof(struct wire_header, kind) == 12);
static_assert(offsetof(struct wire_header, flags) == 14);
static_assert(offsetof(struct wire_header, version) == 15);
static_assert(__STDC_ENDIAN_NATIVE__ == __STDC_ENDIAN_LITTLE__,
"this format is little endian; a big-endian build must convert");
struct careless { /* 같은 멤버를 아무렇게나 늘어놓은 것 */
uint8_t flags;
uint64_t sequence;
uint16_t kind;
uint32_t length;
uint8_t version;
};
int main(void)
{
printf("wire_header: %zu bytes, no padding (8 + 4 + 2 + 1 + 1)\n",
sizeof(struct wire_header));
printf("careless : %zu bytes for the same 16 bytes of members\n",
sizeof(struct careless));
printf(" offsets: flags %zu, sequence %zu, kind %zu, length %zu, version %zu\n",
offsetof(struct careless, flags), offsetof(struct careless, sequence),
offsetof(struct careless, kind), offsetof(struct careless, length),
offsetof(struct careless, version));
return 0;
}
실행 결과
wire_header: 16 bytes, no padding (8 + 4 + 2 + 1 + 1)
careless : 32 bytes for the same 16 bytes of members
offsets: flags 0, sequence 8, kind 16, length 20, version 24
static_assert 여섯 줄이 이 예제의 요점이다. 다른 컴파일러나 다른 옵션으로 빌드했을 때 배치가 달라지면, 프로그램이 다른 바이트를 조용히 쓰는 대신 빌드가 멈춘다. 마지막 static_assert 는 바이트 순서까지 확인한다 — C23 의 <stdbit.h> 가 주는 __STDC_ENDIAN_NATIVE__ 로 「이 기계가 리틀 엔디안인가」를 컴파일 때 묻는다(53장).
같은 멤버를 아무렇게나 늘어놓은 careless 는 16바이트어치 멤버에 32바이트를 쓴다. 배치가 형식이 되는 순간, 멤버의 순서가 형식의 일부가 된다.
흔한 오해. “static_assert 로 크기를 확인했으니 이제 어디서나 같은 바이트다”
50.2.2 제자리 형식 — 복사하지 않고 읽기#
직렬화를 피하는 길을 모은 표의 셋째 줄, 「제자리 형식」(zero-copy format)은 바이트열을 값으로 옮기지 않는다. 대신 바이트열 안의 자리를 계산해 그 자리에서 바로 읽는 접근 함수를 둔다. 「120바이트째부터 4바이트를 리틀 엔디안으로 읽으면 점수다」 같은 식이다. 큰 파일을 열자마자 필요한 필드 몇 개만 읽어야 할 때 유리하다. FlatBuffers 와 Cap’n Proto 가 이 길을 택한 형식으로 알려져 있다 — 이 책은 이름만 적는다.
대가도 분명하다. 옮겨 적는 단계가 없으니 검증하는 단계도 저절로 생기지 않는다. 접근 함수 하나하나가 「이 자리가 바이트열 안인가」를 확인하지 않으면, 조작한 오프셋 하나로 바이트열 밖을 읽는다. 복사를 아낀 만큼 검증은 더 촘촘해야 한다.
50.3 기법 — 커서, 끈적한 오류, 고정 폭#
48장의 encode 는 큰 배열에 오프셋을 더해 가며 썼다. 배열이 넉넉하다고 믿었기 때문에 그 코드는 짧았다. 실무의 직렬화 코드는 그 믿음을 구조로 바꾼다.
50.3.1 커서 — 남은 자리를 데리고 다니는 구조체#
쓰는 쪽과 읽는 쪽에 각각 커서(cursor)를 둔다. 커서는 버퍼의 시작, 크기, 지금 위치를 한 구조체로 묶는다. 모든 put_…·get_… 함수가 커서를 받아, 남은 자리를 먼저 확인한다.
examples/serialization/cursor.c
/* 끈적한 오류 표시를 단 쓰는 커서와 읽는 커서.
모든 put/get 이 남은 자리를 확인한다. 한 번 확인에 걸리면 커서가 「bad」가 되고,
그 뒤의 호출은 아무것도 하지 않는다. 부르는 쪽은 끝에서 한 번만 확인한다. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
struct wcur { unsigned char *p; size_t cap, len; bool bad; };
struct rcur { const unsigned char *p; size_t len, pos; bool bad; };
static void put_bytes(struct wcur *w, const void *src, size_t n)
{
if (w->bad || n > w->cap - w->len) { w->bad = true; return; }
memcpy(w->p + w->len, src, n);
w->len += n;
}
static void put_u8(struct wcur *w, uint8_t v) { put_bytes(w, &v, 1); }
static void put_u16le(struct wcur *w, uint16_t v)
{
unsigned char b[2] = { (unsigned char)v, (unsigned char)(v >> 8) };
put_bytes(w, b, 2);
}
static void put_u32le(struct wcur *w, uint32_t v)
{
unsigned char b[4] = { (unsigned char)v, (unsigned char)(v >> 8),
(unsigned char)(v >> 16), (unsigned char)(v >> 24) };
put_bytes(w, b, 4);
}
static const unsigned char *take(struct rcur *r, size_t n)
{
if (r->bad || n > r->len - r->pos) { r->bad = true; return nullptr; }
const unsigned char *at = r->p + r->pos;
r->pos += n;
return at;
}
static uint8_t get_u8(struct rcur *r)
{ const unsigned char *b = take(r, 1); return b ? b[0] : 0; }
static uint16_t get_u16le(struct rcur *r)
{ const unsigned char *b = take(r, 2); return b ? (uint16_t)(b[0] | b[1] << 8) : 0; }
static uint32_t get_u32le(struct rcur *r)
{
const unsigned char *b = take(r, 4);
return b ? (uint32_t)b[0] | (uint32_t)b[1] << 8
| (uint32_t)b[2] << 16 | (uint32_t)b[3] << 24 : 0;
}
struct player { uint32_t id; uint16_t level; uint8_t name_len; char name[32]; };
static void encode(struct wcur *w, const struct player *pl)
{
put_u32le(w, pl->id);
put_u16le(w, pl->level);
put_u8(w, pl->name_len); /* 길이가 먼저 간다 */
put_bytes(w, pl->name, pl->name_len);
}
static bool decode(struct rcur *r, struct player *pl)
{
pl->id = get_u32le(r);
pl->level = get_u16le(r);
pl->name_len = get_u8(r);
if (pl->name_len >= sizeof pl->name) r->bad = true; /* 형식 자신의 상한 */
const unsigned char *s = take(r, r->bad ? 0 : pl->name_len);
if (r->bad) return false; /* 확인은 한 번, 모든 읽기 뒤에 */
memcpy(pl->name, s, pl->name_len);
pl->name[pl->name_len] = '\0';
return true;
}
int main(void)
{
struct player in = { .id = 0x01020304, .level = 7, .name_len = 5, .name = "alice" };
unsigned char buf[64];
struct wcur w = { .p = buf, .cap = sizeof buf };
encode(&w, &in);
printf("encoded %zu bytes:", w.len);
for (size_t i = 0; i < w.len; i++) printf(" %02X", buf[i]);
printf("\n (sizeof(struct player) is %zu ---"
" the bytes are the format, not the struct)\n\n",
sizeof(struct player));
struct player out;
struct rcur r = { .p = buf, .len = w.len };
printf("whole message : %s", decode(&r, &out) ? "ok" : "rejected");
printf(" -> id %08X, level %u, name \"%s\"\n",
(unsigned)out.id, out.level, out.name);
r = (struct rcur){ .p = buf, .len = w.len - 2 }; /* 짧게 자른다 */
printf("two bytes short : %s\n", decode(&r, &out) ? "ok" : "rejected");
unsigned char lie[64];
memcpy(lie, buf, w.len);
lie[6] = 200; /* 길이가 거짓말을 한다 */
r = (struct rcur){ .p = lie, .len = w.len };
printf("length says 200 : %s\n", decode(&r, &out) ? "ok" : "rejected");
unsigned char tiny[8];
w = (struct wcur){ .p = tiny, .cap = sizeof tiny };
encode(&w, &in);
printf("8-byte buffer : writer is %s after %zu bytes\n",
w.bad ? "bad" : "fine", w.len);
return 0;
}
실행 결과
encoded 12 bytes: 04 03 02 01 07 00 05 61 6C 69 63 65
(sizeof(struct player) is 40 --- the bytes are the format, not the struct)
whole message : ok -> id 01020304, level 7, name "alice"
two bytes short : rejected
length says 200 : rejected
8-byte buffer : writer is bad after 7 bytes
cursor.c 의 커서 설계에는 요령이 셋 있다.
- 모든 범위 확인이 두 함수에 모인다.
put_bytes와take둘만 범위를 본다.put_u32le나get_u16le는 그 둘을 부를 뿐이다. 범위 확인을 함수마다 따로 적으면 언젠가 하나가 빠진다. - 오류가 끈적하다. 한 번 범위를 넘으면 커서의
bad가 켜지고, 그 뒤의 호출은 아무것도 하지 않고 0 을 돌려준다. 그래서decode는 필드마다 확인하지 않고 끝에서 한 번만r->bad를 본다. 확인을 빠뜨릴 자리가 줄어드는 것이 이 설계의 요점이다(끈적한 오류, sticky error). - 덧셈 대신 뺄셈으로 비교한다. 범위 확인은
w->len + n > w->cap이 아니라n > w->cap - w->len이다.len이cap을 넘을 수 없게 지켜 왔으니 뺄셈은 넘치지 않지만, 덧셈은n이 아주 클 때 넘쳐 작은 수가 된다 — 확인이 통과해 버린다.
출력의 마지막 세 줄이 커서가 막은 것이다. 두 바이트 모자란 메시지, 길이를 200 이라고 속인 메시지, 8바이트밖에 없는 버퍼. 셋 다 넘쳐 읽거나 쓰기 전에 멈췄다.
50.3.2 고정 폭과 시프트#
필드는 고정 폭 타입으로 적는다(27장). 바이트 순서는 시프트로 적는다 — (unsigned char)(v >> 8) 은 기계의 엔디안과 상관없이 「v 의 둘째 바이트」다. 시프트와 마스크는 53장에서 정식으로 다루지만, 여기서 쓰는 것은 「바이트 하나를 꺼내고, 바이트 하나를 제자리로 보내기」 두 가지뿐이다.
읽는 쪽은 바이트를 uint32_t 로 먼저 넓힌 뒤에 민다. b[3] << 24 는 b[3] 이 int 로 진급된 뒤 밀리므로, b[3] 이 0x80 이상이면 부호 비트로 들어가 넘친다(30장). (uint32_t)b[3] << 24 로 적는 까닭이 이것이다.
50.3.3 가변 길이 정수 — 작은 수는 짧게#
고정 폭은 단순하지만 낭비가 있다. 「길이 3」을 적는 데 4바이트를 쓴다. 그래서 많은 형식이 가변 길이 정수(variable-length integer, varint)를 쓴다. 바이트마다 아래 7비트에 값을 담고, 맨 위 비트로 「뒤에 바이트가 더 있다」를 알린다. 낮은 7비트 묶음이 먼저 온다. DWARF 디버깅 정보의 LEB128, 프로토콜 버퍼의 varint 가 이 모양이다.1
examples/serialization/varint.c
/* 가변 길이 정수(128진, 「LEB128」·프로토콜 버퍼 방식)와 지그재그.
바이트마다 값 7비트, 낮은 묶음이 먼저. 맨 위 비트는 「뒤에 바이트가 더 있다」.
작은 수는 바이트를 적게 쓴다. */
#include <stdint.h>
#include <stdio.h>
static size_t put_varint(unsigned char *out, uint64_t v)
{
size_t n = 0;
do {
unsigned char b = v & 0x7F;
v >>= 7;
out[n++] = b | (v ? 0x80 : 0); /* 더 있는가? 맨 위 비트를 켠다 */
} while (v);
return n;
}
/* 읽은 바이트 수를 돌려준다. 입력이 잘렸거나 너무 길면 0. */
static size_t get_varint(const unsigned char *in, size_t len, uint64_t *v)
{
uint64_t x = 0;
for (size_t i = 0; i < len && i < 10; i++) { /* 64비트는 많아야 10바이트 */
uint64_t part = in[i] & 0x7F;
if (i == 9 && part > 1) return 0; /* 64비트에 들어가지 않는다 */
x |= part << (7 * i);
if (!(in[i] & 0x80)) { *v = x; return i + 1; }
}
return 0;
}
/* 지그재그: 0,-1,1,-2,2 … -> 0,1,2,3,4 … 작은 음수도 작게 남는다 */
/* 음수를 오른쪽으로 밀지 않는다: 그것은 구현 정의다 */
static uint64_t zigzag(int64_t n)
{ uint64_t u = (uint64_t)n; return (u << 1) ^ (0 - (u >> 63)); }
static int64_t unzigzag(uint64_t u) { return (int64_t)(u >> 1) ^ -(int64_t)(u & 1); }
static void show(const char *label, uint64_t v)
{
unsigned char b[10];
size_t n = put_varint(b, v);
printf(" %-22s ->", label);
for (size_t i = 0; i < n; i++) printf(" %02X", b[i]);
printf(" (%zu byte%s)\n", n, n == 1 ? "" : "s");
}
int main(void)
{
puts("[varint]");
show("1", 1);
show("127", 127);
show("128", 128);
show("150", 150);
show("300", 300);
show("UINT32_MAX", UINT32_MAX);
show("-1 as uint64_t", (uint64_t)-1);
puts("\n[zigzag, then varint]");
for (int64_t n = -2; n <= 2; n++) {
char label[32];
snprintf(label, sizeof label, "%lld -> %llu", (long long)n,
(unsigned long long)zigzag(n));
show(label, zigzag(n));
}
puts("\n[reading back]");
unsigned char good[] = { 0xAC, 0x02 };
unsigned char cut[] = { 0xAC }; /* 「더 있다」 하고 아무것도 없다 */
unsigned char runaway[11] = { 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01 };
uint64_t v = 0;
size_t n = get_varint(good, sizeof good, &v);
printf(" AC 02 -> %llu (%zu bytes)\n", (unsigned long long)v, n);
printf(" AC -> %s\n",
get_varint(cut, sizeof cut, &v) ? "ok" : "rejected: cut short");
printf(" FF x10, 01 -> %s\n",
get_varint(runaway, sizeof runaway, &v) ? "ok" : "rejected: too long");
printf(" unzigzag(3) -> %lld\n", (long long)unzigzag(3));
return 0;
}
실행 결과
[varint]
1 -> 01 (1 byte)
127 -> 7F (1 byte)
128 -> 80 01 (2 bytes)
150 -> 96 01 (2 bytes)
300 -> AC 02 (2 bytes)
UINT32_MAX -> FF FF FF FF 0F (5 bytes)
-1 as uint64_t -> FF FF FF FF FF FF FF FF FF 01 (10 bytes)
[zigzag, then varint]
-2 -> 3 -> 03 (1 byte)
-1 -> 1 -> 01 (1 byte)
0 -> 0 -> 00 (1 byte)
1 -> 2 -> 02 (1 byte)
2 -> 4 -> 04 (1 byte)
[reading back]
AC 02 -> 300 (2 bytes)
AC -> rejected: cut short
FF x10, 01 -> rejected: too long
unzigzag(3) -> -2
127 까지는 한 바이트, 128 부터 두 바이트다. 그런데 -1 을 그대로 부호 없는 64비트로 보면 10바이트가 된다 — 비트가 모두 1 이기 때문이다. 그래서 음수가 흔한 값에는 지그재그(zigzag)를 먼저 씌운다. 0, −1, 1, −2, 2 를 0, 1, 2, 3, 4 에 대응시키면, 절댓값이 작은 수는 부호와 상관없이 짧아진다.
예제의 zigzag 는 음수를 오른쪽으로 미는 대신 부호 없는 수로 바꾼 뒤 맨 위 비트를 꺼낸다. 음수의 오른쪽 시프트는 구현 정의이기 때문이다(29장). 그리고 읽는 쪽의 get_varint 는 열 바이트에서 멈춘다. 「뒤에 더 있다」를 끝없이 켜 둔 입력이 반복을 끝없이 돌리거나 64비트 밖으로 시프트하게 두지 않는다. 가변 길이는 읽는 쪽이 길이를 미리 모른다는 뜻이고, 그래서 상한을 읽는 쪽이 정해야 한다.
50.3.4 태그·길이·값 — 모르는 것을 건너뛰기#
형식은 판이 바뀐다. 멤버가 더해지고, 언젠가는 빠진다. 그때 옛 판의 읽는 쪽이 새 판의 파일을 만나도 죽지 않게 하는 고전적인 방법이 태그·길이·값(tag-length-value, TLV)이다. 필드마다 「무엇인가(태그)」, 「몇 바이트인가(길이)」, 「값」을 적는다. 길이가 있으니 모르는 태그는 그 길이만큼 건너뛰면 된다.
examples/serialization/tlv.c
/* 작은 파일 형식: 매직, 판 번호, 그리고 태그·길이·값 필드들.
판 1 을 위해 만든 읽는 쪽이 판 2 가 쓴 파일을 만나, 들어 본 적 없는 필드를
건너뛴다 --- 필드마다 자기 길이를 말하기 때문이다. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
enum { TAG_NAME = 1, TAG_SCORE = 2, TAG_COLOUR = 3 /* 판 2 에서 더함 */ };
enum { MAX_FIELD = 64 }; /* 형식의 약속: 이보다 긴 필드는 없다 */
static const unsigned char magic[4] = { 'P', 'C', 'B', 0x1A };
static size_t put_field(unsigned char *out, uint8_t tag, const void *v, uint8_t len)
{
out[0] = tag; out[1] = len;
memcpy(out + 2, v, len);
return 2u + len;
}
static size_t write_v2(unsigned char *out)
{
size_t n = 0;
memcpy(out, magic, 4); n += 4;
out[n++] = 2; /* 판 번호 */
n += put_field(out + n, TAG_NAME, "alice", 5);
n += put_field(out + n, TAG_COLOUR, "\xFF\x80\x00", 3); /* 판 2 의 새 필드 */
unsigned char score[4] = { 0x39, 0x05, 0, 0 }; /* 1337, 리틀 엔디안 */
n += put_field(out + n, TAG_SCORE, score, 4);
return n;
}
/* 판 1 의 읽는 쪽: NAME 과 SCORE 만 안다. */
static int read_v1(const unsigned char *in, size_t len)
{
if (len < 5 || memcmp(in, magic, 4) != 0) { puts(" not our file"); return 0; }
unsigned version = in[4];
printf(" version %u file\n", version);
size_t pos = 5;
while (pos < len) {
if (len - pos < 2) { puts(" rejected: field header cut short"); return 0; }
unsigned tag = in[pos], flen = in[pos + 1];
if (flen > MAX_FIELD)
{ printf(" rejected: field of %u bytes\n", flen); return 0; }
if (flen > len - pos - 2)
{ puts(" rejected: field runs past the end"); return 0; }
const unsigned char *v = in + pos + 2;
switch (tag) {
case TAG_NAME:
printf(" name = %.*s\n", (int)flen, (const char *)v);
break;
case TAG_SCORE:
if (flen != 4) { puts(" rejected: score is not 4 bytes"); return 0; }
printf(" score = %u\n", (unsigned)(v[0] | v[1] << 8
| (uint32_t)v[2] << 16 | (uint32_t)v[3] << 24));
break;
default:
printf(" (skipped unknown tag %u, %u bytes)\n", tag, flen);
}
pos += 2u + flen;
}
return 1;
}
int main(void)
{
unsigned char file[128];
size_t n = write_v2(file);
printf("[a version-2 file, %zu bytes, read by a version-1 reader]\n", n);
read_v1(file, n);
puts("\n[the same file with the last field's length raised to 9]");
file[n - 5] = 9;
read_v1(file, n);
puts("\n[a field that claims 200 bytes]");
file[6] = 200;
read_v1(file, n);
return 0;
}
실행 결과
[a version-2 file, 23 bytes, read by a version-1 reader]
version 2 file
name = alice
(skipped unknown tag 3, 3 bytes)
score = 1337
[the same file with the last field's length raised to 9]
version 2 file
name = alice
(skipped unknown tag 3, 3 bytes)
rejected: field runs past the end
[a field that claims 200 bytes]
version 2 file
rejected: field of 200 bytes
판 2 가 「색」이라는 필드를 더했지만, 판 1 의 읽는 쪽은 그 필드를 건너뛰고 이름과 점수를 제대로 읽었다. 뒤의 두 실험은 같은 구조의 다른 면이다. 길이가 있다는 것은 길이가 거짓말을 할 수 있다는 뜻이다. 그래서 읽는 쪽은 길이를 두 번 확인한다 — 형식이 정한 상한(MAX_FIELD)을 넘는가, 남은 바이트를 넘는가.
문. 판 번호가 있는데 왜 태그까지 필요한가? 판 번호만 보고 갈라 읽으면 되지 않는가?
답. 둘은 다른 일을 한다. 판 번호는 뜻이 바뀌었을 때 쓴다 — 같은 필드의 단위가 밀리초에서 마이크로초로 바뀌었다면, 옛 읽는 쪽은 그 파일을 읽지 말아야 한다. 태그는 더해졌을 때 쓴다 — 새 필드는 옛 읽는 쪽이 몰라도 되는 것이라 건너뛰면 된다. 그래서 흔한 설계는 둘을 함께 둔다. 판 번호를 「큰 판」과 「작은 판」으로 나누어, 큰 판이 다르면 읽기를 거절하고 작은 판이 다르면 모르는 태그를 건너뛰는 식이다. 예제의 판 1 읽는 쪽은 판 번호를 인쇄만 했다 — 실무라면 큰 판을 확인해야 한다.
50.3.5 실수를 보내는 법#
실수에는 시프트를 쓸 수 없다. 흔한 방법은 float 를 memcpy 로 uint32_t 에 담아 정수로 보내는 것이다 — 같은 비트를 다른 타입으로 다시 읽는 계약 안의 방법이다(49·57장). 읽는 쪽은 거꾸로 한다. 이 방법은 두 쪽이 같은 실수 형식을 쓴다는 가정 위에 서 있다. C23 은 구현이 IEC 60559(IEEE 754) 이진 실수를 따르면 __STDC_IEC_60559_BFP__ 를 정의한다(54장). 형식 문서에 「IEEE 754 binary32, 리틀 엔디안」이라고 적고, 그 매크로가 없는 빌드는 멈추게 하는 것이 정직하다.
50.4 팩과 패딩 — 구조체를 형식에 맞추지 않는다#
48장에서 __attribute__((packed)) 와 #pragma pack 을 보았다. 둘 다 표준이 아니고, 패딩을 없애 구조체를 형식에 맞추는 장치다. 여기에 packed 멤버의 정렬 문제 하나를 더해야 한다.
packed 구조체의 멤버는 정렬이 맞지 않을 수 있다. 컴파일러는 그 멤버를 이름으로 읽을 때는 정렬이 안 맞는 줄 알고 조심스러운 명령을 내지만, 그 멤버의 주소를 꺼내 넘기면 받는 쪽은 그 사정을 모른다. uint32_t * 로 받은 함수는 정렬이 맞는다고 믿고 읽는다 — 정렬을 엄격히 요구하는 기계에서는 그 자리에서 멈추고, 그렇지 않은 기계에서도 계약 밖이다(38장). GCC 와 Clang 은 이 자리를 -Waddress-of-packed-member 로 경고한다(GCC 는 9 판부터 기본으로 켠다).
반례. packed 구조체로 패킷을 「그대로 덮어 읽기」
struct __attribute__((packed)) hdr { uint8_t kind; uint32_t len; };
const struct hdr *h = (const struct hdr *)buf; /* 받은 바이트 위에 덮는다 */
if (h->len > room) ...세 가지가 동시에 틀린다. buf 를 다른 타입으로 읽는 것은 유효 타입 규칙에 걸리고(57장), 바이트 순서는 여전히 이 기계의 것이며, 패킷이 sizeof(struct hdr) 보다 짧은지 보기 전에 이미 h->len 을 읽었다. 커서로 한 필드씩 읽으면 세 문제가 함께 사라진다. 「패킷 위에 구조체를 덮는다」는 C 코드에서 가장 흔한 형식 해석 방식이지만, 표준 C 로 정직하게 쓸 수 있는 방식은 아니다.
그래서 이 책의 권고는 단순하다. 형식은 바이트로 읽고 쓴다. 구조체는 기억 속에서 편한 모양으로 두고, 둘 사이를 커서로 옮긴다. packed 는 바이트 배치가 바깥에서 정해진 하드웨어 레지스터처럼 정말로 그 모양의 기억을 다룰 때를 위해 남겨 둔다(114장).
50.5 엔디안 변환 — 세 갈래#
바이트 순서를 바꾸는 방법은 세 갈래다.
| 방법 | 어디서 되는가 | 특징 |
|---|---|---|
| 시프트로 한 바이트씩 | 표준 C 어디서나 | 기계의 엔디안을 묻지 않는다. 이 책의 권고 |
memcpy + 바이트 뒤집기 내장 함수 | GCC·Clang __builtin_bswap32, MSVC _byteswap_ulong | __STDC_ENDIAN_NATIVE__ 로 뒤집을지를 가른다 |
htonl·ntohl 등 | POSIX·Windows 소켓 API | 네트워크 순서(빅 엔디안) 전용, 16·32비트만. 표준 C 가 아니다 |
표 50.4 — 바이트 순서를 바꾸는 세 갈래
C23 의 <stdbit.h> 는 바이트 순서를 묻는 매크로 셋만 준다 — __STDC_ENDIAN_LITTLE__, __STDC_ENDIAN_BIG__, __STDC_ENDIAN_NATIVE__(7.18.2). 바꾸는 함수는 없다. 그리고 표준은 리틀도 빅도 아닌 순서가 있을 수 있다고 적어 두었다 — 그때 __STDC_ENDIAN_NATIVE__ 는 둘 어느 쪽과도 다른 값이다. 「리틀이 아니면 빅」이라고 쓰면 그 경우를 놓친다.
그렇다면 시프트로 한 바이트씩 조립하는 코드는 느리지 않은가? 컴파일된 기계어 명령의 수를 직접 세어 볼 수 있다.
examples/serialization/bswap/load.c
/* 바이트에서 32비트 빅 엔디안 값을 읽는 두 방법.
어느 쪽도 결과가 기계의 바이트 순서에 따라 달라지지 않는다. (2)는 리틀 · 빅 엔디안 기계만 다룬다. */
#include <stdint.h>
#include <string.h>
/* (1) 시프트: 이식되는 C, 표준 밖의 것이 없다 */
uint32_t get_u32be_shift(const unsigned char *p)
{
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16
| (uint32_t)p[2] << 8 | (uint32_t)p[3];
}
/* (2) uint32_t 에 memcpy 한 뒤, 이 기계가 리틀 엔디안이면 바이트를 뒤집는다.
__builtin_bswap32 는 GCC·Clang 확장이다. MSVC 에서는 _byteswap_ulong. */
#include <stdbit.h>
uint32_t get_u32be_bswap(const unsigned char *p)
{
uint32_t v;
memcpy(&v, p, sizeof v);
#if __STDC_ENDIAN_NATIVE__ == __STDC_ENDIAN_LITTLE__
v = __builtin_bswap32(v);
#elif __STDC_ENDIAN_NATIVE__ != __STDC_ENDIAN_BIG__
#error "byte order is neither little nor big endian"
#endif
return v;
}
/* 시프트로 리틀 엔디안 --- 리틀 엔디안 기계에서는 그냥 읽기 하나 */
uint32_t get_u32le_shift(const unsigned char *p)
{
return (uint32_t)p[0] | (uint32_t)p[1] << 8
| (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}
실행 결과
-O0 get_u32be_shift 27 instructions
-O0 get_u32be_bswap 12 instructions (uses bswap)
-O0 get_u32le_shift 26 instructions
-O2 get_u32be_shift 3 instructions (uses bswap)
-O2 get_u32be_bswap 3 instructions (uses bswap)
-O2 get_u32le_shift 2 instructions
-O2 -march=x86-64-v3 get_u32be_shift 2 instructions (uses movbe)
-O2 -march=x86-64-v3 get_u32be_bswap 2 instructions (uses movbe)
-O2 -march=x86-64-v3 get_u32le_shift 2 instructions
플랫폼 노트. 이 기계에서 센 것
최적화하지 않으면(-O0) 시프트로 조립하는 두 함수는 스무 개가 넘는 명령이 된다. -O2 에서는 두 컴파일러 모두 빅 엔디안을 읽는 get_u32be_shift 를 한 번 읽고 바이트를 뒤집는 세 명령으로 바꾼다 — 손으로 __builtin_bswap32 를 쓴 함수와 같다. -march=x86-64-v3 처럼 MOVBE 명령(읽으면서 뒤집기)이 있는 기계를 겨냥하면 두 명령이 된다. 리틀 엔디안을 리틀 엔디안 기계에서 읽는 get_u32le_shift 는 그냥 읽기 하나다.
결론은 「시프트로 쓰고 컴파일러에게 맡긴다」이다. 이식성을 잃지 않고 같은 기계어를 얻는다. 다만 이것은 최적화를 켰을 때의 이야기다 — 디버그 빌드의 속도로 형식 코드를 판단하지 않는다.
50.6 버퍼 구조와 그 최적화#
직렬화한 바이트는 어딘가에 모였다가 나간다. 그 「어딘가」의 모양이 속도를 정하는 경우가 많다.
50.6.1 모아서 한 번에#
필드 하나를 쓸 때마다 send 나 write 를 부르면, 바이트 수는 같아도 호출 수가 필드 수만큼 된다. 그런 호출 하나하나는 대개 운영체제 커널로 건너가는 길이라(10장) 바이트 몇 개를 옮기는 일보다 훨씬 비싸다.
examples/serialization/batch.c
/* 필드마다 쓰기와, 먼저 버퍼 하나에 모으기.
`sink` 는 호출마다 비싼 것을 대신한다 --- 소켓의 send(), 파일의 write(), USB 전송.
여기서는 세기만 한다. */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static unsigned long calls, bytes;
static void sink(const void *p, size_t n) { (void)p; calls++; bytes += n; }
struct point { int32_t x, y; uint16_t id; };
static void put_le(unsigned char *b, uint32_t v, int n)
{ for (int i = 0; i < n; i++) b[i] = (unsigned char)(v >> (8 * i)); }
/* (1) 필드마다 따로 나간다 */
static void send_each(const struct point *pts, size_t count)
{
unsigned char b[4];
for (size_t i = 0; i < count; i++) {
put_le(b, (uint32_t)pts[i].x, 4); sink(b, 4);
put_le(b, (uint32_t)pts[i].y, 4); sink(b, 4);
put_le(b, pts[i].id, 2); sink(b, 2);
}
}
/* (2) 버퍼를 채우고, 차면 보내고, 끝에 한 번 더 */
static void send_batched(const struct point *pts, size_t count)
{
unsigned char buf[4096];
size_t used = 0;
for (size_t i = 0; i < count; i++) {
if (sizeof buf - used < 10) { sink(buf, used); used = 0; }
put_le(buf + used, (uint32_t)pts[i].x, 4);
put_le(buf + used + 4, (uint32_t)pts[i].y, 4);
put_le(buf + used + 8, pts[i].id, 2);
used += 10;
}
if (used) sink(buf, used);
}
int main(void)
{
static struct point pts[10000];
for (size_t i = 0; i < 10000; i++)
pts[i] = (struct point){ (int32_t)i, -(int32_t)i, (uint16_t)i };
send_each(pts, 10000);
unsigned long each = calls;
printf("field by field : %6lu calls, %lu bytes\n", calls, bytes);
calls = bytes = 0;
send_batched(pts, 10000);
printf("4 KiB buffer : %6lu calls, %lu bytes\n", calls, bytes);
printf("\nsame bytes, one call where there were %lu.\n", each / calls);
puts("If one call costs a trip into the kernel, that is where the time goes.");
puts("stdio's FILE does this for you; a raw socket does not.");
return 0;
}
실행 결과
field by field : 30000 calls, 100000 bytes
4 KiB buffer : 25 calls, 100000 bytes
same bytes, one call where there were 1200.
If one call costs a trip into the kernel, that is where the time goes.
stdio's FILE does this for you; a raw socket does not.
같은 10만 바이트를 3만 번에 나눠 보내느냐 25번에 보내느냐의 차이다. FILE 을 쓰는 표준 입출력은 이 일을 이미 해 준다 — fwrite 는 내부 버퍼에 모았다가 찼을 때 내보낸다(78장). 소켓처럼 그 층이 없는 자리에서는 직접 모아야 한다.
50.6.2 자라는 버퍼#
출력의 크기를 미리 모를 때는 자라는 버퍼를 쓴다. 모자라면 용량을 두 배로 늘린다 — 한 바이트씩 늘리면 realloc 이 매번 복사해 전체가 제곱의 비용이 되지만, 두 배씩 늘리면 바이트당 복사가 평균 상수 번으로 묶인다. 용량 계산은 넘침을 확인한다(ckd_mul, 96장). 그리고 realloc 이 실패하면 옛 버퍼가 그대로 남는다는 것을 기억한다(57장) — p = realloc(p, n) 은 실패할 때 옛 버퍼를 잃는다.
크기를 미리 알 수 있다면 더 좋다. 두 번 돈다 — 첫 번째는 쓰지 않고 크기만 세는 커서로, 두 번째는 정확한 크기의 버퍼에 쓴다. 커서 구조체에 「세기만 하는」 모드를 두면 같은 encode 함수로 두 일을 다 한다.
50.6.3 고리 버퍼와 두 벌 버퍼#
받는 쪽은 바이트가 조각나서 온다. 네트워크는 메시지 경계를 지켜 주지 않는다 — 한 번의 받기가 메시지 반 개를 줄 수도, 세 개 반을 줄 수도 있다. 그래서 받는 쪽은 고리 버퍼(ring buffer)에 바이트를 쌓고, 메시지 하나가 다 모였을 때만 해석한다. 용량을 2의 거듭제곱으로 잡으면 「끝에 닿으면 처음으로」를 나머지 연산 대신 마스크 하나로 한다(i & (cap - 1)).
화면이나 소리처럼 쓰는 쪽과 읽는 쪽이 동시에 도는 자리에서는 두 벌 버퍼(double buffering)를 쓴다. 한 벌을 채우는 동안 다른 벌을 내보내고, 다 되면 역할을 바꾼다. 흩어진 조각 여러 개를 복사 없이 한 번에 내보내는 POSIX writev 도 같은 결의 도구다(표준 C 가 아니다).
50.6.4 다시 쓰는 버퍼의 찌꺼기#
버퍼를 다시 쓰는 것은 좋은 최적화지만 함정이 하나 있다. 앞 메시지가 100바이트였고 이번 메시지가 60바이트라면, 버퍼의 61~100바이트째에는 앞 메시지가 남아 있다. 길이를 잘못 계산해 100바이트를 내보내면 앞 메시지의 꼬리가 함께 나간다. 48장의 「패딩이 새어 나간 사고」와 같은 부류다 — 내보낸 적 없는 바이트가 나간다. 내보내는 길이는 이번에 쓴 길이(커서의 len)로만 정한다.
50.7 형식을 설계하는 원칙#
바이트를 다루는 기법은 위의 것으로 충분하다. 어려운 것은 형식을 정하는 일이다. 한 번 바깥에 나간 형식은 거둬들일 수 없기 때문이다.
| 원칙 | 까닭 |
|---|---|
| 맨 앞에 매직 넘버 | 엉뚱한 파일을 일찍 알아본다. PNG 의 첫 8바이트처럼 텍스트 변환에 망가지는 바이트를 섞으면 전송 사고도 알아본다 |
| 판 번호, 그리고 큰 판·작은 판 | 뜻이 바뀌면 큰 판, 필드가 더해지면 작은 판(위의 문답) |
| 바이트 순서를 문서에 못박는다 | 오늘 새로 짓는 형식은 리틀 엔디안이 흔하고, 네트워크 규약은 빅 엔디안이 관례다. 어느 쪽이든 적어 두는 것이 원칙이다 |
| 고정 폭, 또는 상한이 있는 가변 길이 | int·long·size_t·포인터는 형식에 나오지 않는다 |
| 모든 길이·개수·깊이에 상한 | 읽는 쪽이 자원을 미리 셈할 수 있다. 상한이 없는 형식은 공격면이 된다 |
| 모르는 것을 건너뛸 수 있게 | 태그·길이·값. 옛 읽는 쪽이 새 파일에 죽지 않는다 |
| 정준 형식을 하나로 | 같은 값은 같은 바이트 — 서명·해시·비교가 가능해진다. 가변 길이 정수는 「쓸데없이 긴 부호화」를 거절해야 한다 |
| 무결성과 진정성을 가른다 | CRC(cyclic redundancy check, 순환 중복 검사)는 사고로 망가진 것을 찾는다. 누가 일부러 바꾼 것은 못 찾는다 — 그것은 MAC(message authentication code, 메시지 인증 부호)·서명의 일이다 |
| 시험 벡터를 함께 낸다 | 「이 값은 이 바이트」의 짝을 문서에 싣는다. 두 구현이 어긋나는 것을 처음부터 잡는다 |
| 텍스트로 충분한지 먼저 묻는다 | 사람이 읽고 고칠 수 있는 것의 가치는 크다(48장의 문답) |
표 50.5 — 이진 형식을 설계할 때의 원칙
문. 「보내는 것은 엄격하게, 받는 것은 관대하게」라는 말이 있다. 받는 쪽이 관대해야 하지 않는가?
답. 인터넷 초기의 IP·TCP 명세(RFC 760, 1980 · RFC 793, 1981)에 실린 「견고성 원칙」이고, 오랫동안 미덕으로 여겨졌다. 그런데 관대하게 받는 구현이 많아지면 보내는 쪽이 틀려도 아무도 모르게 되고, 형식은 문서가 아니라 「가장 관대한 구현이 받아 주는 것」이 된다. 그 틈이 해석기 사이의 차이를 낳고, 그 차이가 공격이 된다 — 같은 바이트를 두 구현이 다르게 읽으면, 검사하는 쪽과 그 값을 사용하는 쪽이 다른 것을 본다. 그래서 요즘의 형식 설계는 받는 것도 엄격하게 쪽으로 기운다 — 인터넷 아키텍처 위원회(IAB)의 RFC 9413(2023)은 이 원칙을 지나치게 적용하면 구현들이 갈라지고 오류가 굳어진다고 정리하고, 틀린 입력을 조용히 받아 주는 대신 명세를 고치고 오류 처리를 명세에 적으라고 권한다. 관대함이 필요하면 「무엇에 관대한가」를 형식에 적는다 — 모르는 태그는 건너뛰되, 길이가 틀린 태그는 거절한다.
50.8 보안 — 바깥에서 온 바이트는 모두 상대가 쓴 것이다#
역직렬화 코드의 첫 규칙은 하나다. 입력의 모든 바이트는 적이 골랐다고 가정한다. 길이도, 개수도, 오프셋도, 판 번호도. 아래 사고들은 모두 그 가정 하나를 어긴 자리에서 났다.
| 갈래 | 무엇을 믿었는가 | 막는 것 |
|---|---|---|
| 길이를 믿기 | 「길이 필드가 말한 만큼 바이트가 있다」 | 커서가 남은 바이트와 비교한다 |
| 크기 계산의 넘침 | 「너비 × 높이 × 4 는 넘치지 않는다」 | ckd_mul, 상한 |
| 개수를 믿고 할당 | 「10억 개라면 10억 개 자리를 잡는다」 | 남은 바이트로 개수의 상한을 먼저 셈한다 |
| 중첩 깊이 | 「목록 안의 목록은 몇 겹 안 된다」 | 깊이 상한, 또는 재귀 대신 명시적 스택 |
| 「올바른 입력」만 가정한 표 크기 | 「올바른 부호라면 표가 이만큼이면 된다」 | 쓰기 전에 필요한 크기를 셈한다 |
| 표현의 혼동 | 「이 바이트열의 뜻은 하나다」 | 정준 형식, 엄격한 해석 |
| 두 번 읽기(double fetch) | 「공유 메모리의 값은 내가 확인한 뒤에도 그대로다」 | 한 번 복사한 뒤 복사본을 검증한다 |
| 내보낸 적 없는 바이트 | 「버퍼에는 내가 쓴 것만 있다」 | 쓴 길이만 내보낸다, 0 으로 채운다 |
표 50.6 — 역직렬화에서 나는 사고의 갈래
표의 갈래 가운데 셋 — 크기 계산의 넘침, 개수를 믿고 할당, 중첩 깊이 — 를 한 예제에서 본다.
examples/serialization/hostile.c
/* 적의를 품은 입력이 할 수 있는 거짓말 셋과, 그것을 거절하는 확인.
여기서는 아무것도 넘치지 않는다: 확인하지 않은 쪽은 계산해서 인쇄만 한다. */
#include <stdckdint.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
/* (1) 머리말이 너비 × 높이 × 4 바이트라고 한다. 32비트로 곱하면 넘쳐 되돈다. */
static void image_size(uint32_t w, uint32_t h)
{
uint32_t naive = w * h * 4u; /* 부호 없음: 되돌 뿐 UB 아님 --- 경고도 없다 */
size_t checked;
bool over = ckd_mul(&checked, (size_t)w, (size_t)h)
|| ckd_mul(&checked, checked, (size_t)4);
printf(" %10u x %-10u naive 32-bit size %10u",
(unsigned)w, (unsigned)h, (unsigned)naive);
if (over) puts(" checked: refused (does not fit in size_t)");
else if (checked > 256u << 20)
printf(" checked: %zu, refused (over the 256 MiB limit)\n", checked);
else printf(" checked: %zu, accepted\n", checked);
}
/* (2) 개수가 「항목 10억 개가 뒤따른다」고 하는데 입력에는 몇 바이트뿐이다.
할당하기 *전에* 그 주장을 실제로 있는 것과 견준다. */
static void read_items(uint32_t claimed, size_t bytes_left, size_t item_size)
{
printf(" claims %10u items, %3zu bytes left: ", (unsigned)claimed, bytes_left);
if (claimed > bytes_left / item_size) { puts("refused before malloc"); return; }
void *p = malloc((size_t)claimed * item_size);
puts(p ? "allocated" : "allocation failed");
free(p);
}
/* (3) 중첩: 0x01 은 목록을 열고 0x02 는 닫는다. 재귀로 읽는 쪽에는
깊이 상한이 필요하다 --- 없으면 0x01 이 길게 이어진 입력이 스택을 다 쓴다. */
enum { MAX_DEPTH = 32 };
static const unsigned char *parse_list(const unsigned char *p,
const unsigned char *end, int depth, bool *ok)
{
if (depth > MAX_DEPTH) { *ok = false; return end; }
while (p < end && *ok) {
if (*p == 0x01) p = parse_list(p + 1, end, depth + 1, ok);
else if (*p == 0x02) return p + 1;
else p++;
}
return p;
}
int main(void)
{
puts("[1] sizes from a header");
image_size(640, 480);
image_size(65536, 16385);
image_size(0x40000000u, 4);
image_size(UINT32_MAX, UINT32_MAX);
puts("\n[2] counts from a header (8-byte items)");
read_items(3, 24, 8);
read_items(1000000000u, 24, 8);
puts("\n[3] nesting depth");
static unsigned char deep[100000];
for (size_t i = 0; i < sizeof deep; i++) deep[i] = 0x01;
unsigned char fine[] = { 0x01, 0x01, 0x07, 0x02, 0x02 };
bool ok = true;
parse_list(fine, fine + sizeof fine, 0, &ok);
printf(" 5 bytes, depth 2 : %s\n", ok ? "accepted" : "refused");
ok = true;
parse_list(deep, deep + sizeof deep, 0, &ok);
printf(" 100000 opens in a row : %s at depth %d\n",
ok ? "accepted" : "refused", MAX_DEPTH + 1);
return 0;
}
실행 결과
[1] sizes from a header
640 x 480 naive 32-bit size 1228800 checked: 1228800, accepted
65536 x 16385 naive 32-bit size 262144 checked: 4295229440, refused (over the 256 MiB limit)
1073741824 x 4 naive 32-bit size 0 checked: 17179869184, refused (over the 256 MiB limit)
4294967295 x 4294967295 naive 32-bit size 4 checked: refused (does not fit in size_t)
[2] counts from a header (8-byte items)
claims 3 items, 24 bytes left: allocated
claims 1000000000 items, 24 bytes left: refused before malloc
[3] nesting depth
5 bytes, depth 2 : accepted
100000 opens in a row : refused at depth 33
첫째 묶음은 크기 계산이다. 65536 × 16385 × 4 를 32비트에서 곱하면 넘쳐서 262144 가 된다. 부호 없는 정수의 넘침은 정의되지 않은 동작, 곧 UB(undefined behaviour)가 아니라 정의된 나머지라 컴파일러도 경고하지 않는다(29장). 그 작은 수로 할당하고 원래 크기만큼 쓰면 힙 넘침이다. ckd_mul 로 size_t 에서 곱하고, 형식이 정한 상한(여기서는 256 MiB)과 견주면 넘치는 곱도, 넘치지 않지만 상한을 넘는 크기도 막힌다.
둘째 묶음은 개수다. 「항목 10억 개」를 믿고 할당부터 하면, 24바이트짜리 입력 하나로 몇 GB 를 잡게 할 수 있다. 할당 전에 「남은 바이트로 그만큼의 항목이 들어갈 수 있는가」를 본다.
셋째 묶음은 깊이다. 재귀로 짠 해석기에 「목록을 연다」만 10만 번 이어진 입력을 주면 스택이 다 쓰인다. 깊이 상한을 두면 33 번째 겹에서 멈춘다.
50.8.1 실제로 난 사고들#
실제 사례. zlib — 헤더의 길이 필드 (CVE-2022-37434)
zlib 는 gzip·PNG·HTTP 압축 등 거의 모든 곳에 들어 있는 압축 라이브러리다. 1.2.12 까지의 inflate(inflate.c)는 gzip 헤더의 「extra 필드」가 클 때 힙 버퍼를 넘쳐 쓰거나 넘쳐 읽었다. 헤더 정보를 받으려고 inflateGetHeader 를 부르는 응용만 해당했다.2
헤더의 길이 필드는 보낸 쪽이 쓴 값이다. 그 값을 받는 쪽 버퍼의 크기와 견주는 일이 한 경로에서 빠졌다. 가장 오래되고 가장 많이 검토된 C 라이브러리 가운데 하나에서도 「길이를 믿기」 갈래의 사고는 나온다.
실제 사례. Stagefright 와 FORCEDENTRY — 곱셈 하나
2015년 안드로이드의 미디어 라이브러리 libstagefright 는 MP4 파일의 「atom」을 해석하다가 검사하지 않은 곱셈이 넘쳐 원격 코드 실행에 이르렀다(CVE-2015-1538, SampleTable::setSampleToChunkParams, 안드로이드 5.1.1 LMY48I 전). 이 코드는 C++ 이지만 넘친 것은 C 와 같은 정수 곱셈이다.
2021년의 FORCEDENTRY(CVE-2021-30860)는 더 정교했다. iMessage 는 .gif 로 끝나는 파일을 받으면 이미지 라이브러리에 넘기는데, 그 라이브러리는 확장자가 아니라 내용을 보고 형식을 짐작했다. 그래서 이름만 GIF 인 PDF 가 PDF 해석기로 갔다. 그 안의 JBIG2 디코더가 참조하는 기호 사전들의 크기를 32비트 numSyms 에 더하다가 넘쳐 작은 버퍼를 잡았고, 뒤의 쓰기가 그 끝을 넘었다. Google Project Zero 는 이것을 「기술적으로 가장 정교한 공격 가운데 하나」로 분석했다.3
두 사고의 교훈은 같다. 크기는 입력에서 계산된 순간 입력이다. 곱하고 더한 결과까지 적이 고른 것이다.
실제 사례. libwebp — 「올바른 입력이라면」 (CVE-2023-4863)
WebP 무손실 형식은 허프만 부호를 쓴다. libwebp 는 허프만 표를 담을 메모리를 미리 계산해 둔 고정 크기로 잡았다. 그 크기는 「올바르고 완전한 부호」라면 넘을 수 없는 값으로 정확히 계산되어 있었다. 그런데 조작한 잘못된 부호는 마지막 일관성 검사에서 거절되기 전에 표를 만드는 도중 그 끝을 넘어 썼다. OSS-Fuzz 가 오랫동안 돌렸지만, 최대 크기의 올바른 표 여러 개 뒤에 특정한 잘못된 표 하나가 와야 하는 조건이라 찾지 못했다. 수정은 쓰기 전에 필요한 크기를 먼저 세는 단계를 더한 것이다.4
표 크기의 계산이 틀린 것이 아니다. 그 계산이 입력이 올바르다는 가정 위에 서 있었고, 검사는 그 가정이 깨진 뒤에 왔다. 검증은 쓰기보다 먼저 와야 한다.
실제 사례. OpenSSL 의 ASN.1 — 같은 값의 다른 바이트 (CVE-2016-2108)
실제 사례. 두 번 읽기 — 확인한 뒤에 바뀌는 값
wire.c)에도 그대로 적용된다.길이를 믿은 사고의 대표인 Heartbleed 는 43–44장에서, 생성된 해석기가 끝을 넘은 Cloudbleed 는 57장에서 보았다.
50.8.2 막는 구조#
사고를 막는 것은 주의가 아니라 구조다. 위의 사고들은 모두 숙련된 사람들이 쓴 코드에서 났다.
| 구조 | 막는 갈래 |
|---|---|
| 모든 읽기가 커서를 거친다(범위 확인이 한 함수에) | 길이를 믿기, 끝을 넘어 읽기 |
크기 계산은 ckd_mul·ckd_add, 그리고 상한 | 크기 계산의 넘침, 큰 할당 |
| 개수는 남은 바이트로 먼저 묶는다 | 개수를 믿은 할당 |
| 깊이 상한, 반복 상한 | 중첩 깊이, 끝없는 반복 |
| 쓰기 전에 필요한 크기를 센다 | 「올바른 입력」 가정 |
| 엄격한 해석, 정준 형식 | 표현의 혼동 |
| 한 번 복사한 뒤 검증 | 두 번 읽기 |
| 퍼징(libFuzzer·AFL 같은 도구)과 새니타이저를 늘 켠 시험 빌드 | 사람이 생각하지 못한 입력 — 단 libwebp 처럼 조건이 까다로우면 퍼징도 놓친다 |
표 50.7 — 역직렬화를 지키는 구조
퍼징(fuzzing)은 무작위로, 또는 실행 경로를 보아 가며 입력을 바꿔 해석기에 끝없이 먹이는 시험이다. ASan 같은 새니타이저(57장)와 함께 돌리면, 넘침이 일어난 순간 멈춘다. 형식 해석기를 쓰는 사람이 할 수 있는 가장 값싼 보험이다.
50.9 정리#
| 주제 | 요점 |
|---|---|
| 무엇인가 | 기억 속 값을 바깥과 합의한 바이트열로. 기억 모양은 구현의 약속이고, 형식은 바깥과의 약속이다 |
| 직렬화 피하기 | 같은 빌드를 쥔 자리에서만. 배치를 static_assert 로 못박고, 엔디안도 못박는다 |
| 기법 | 커서(범위 확인은 한 곳), 끈적한 오류, 고정 폭과 시프트, 가변 길이 정수와 지그재그, 태그·길이·값 |
| 팩과 패딩 | 구조체를 형식에 맞추지 말고 형식을 바이트로 읽는다. packed 멤버의 주소를 넘기지 않는다 |
| 엔디안 | 시프트로 쓰고 최적화에 맡긴다. C23 에는 바이트 순서를 묻는 매크로만 있고 바꾸는 함수는 없다 |
| 버퍼 | 모아서 한 번에, 두 배씩 자라기, 고리 버퍼, 쓴 길이만 내보내기 |
| 형식 설계 | 매직·판·엔디안·상한·건너뛰기·정준 형식·시험 벡터. 받는 것도 엄격하게 |
| 보안 | 입력의 모든 바이트는 적이 골랐다. 검증은 메모리에 쓰기보다 먼저, 크기는 넘침을 확인하고, 깊이와 개수에 상한 |
표 50.8 — 직렬화 — 한 줄씩
직렬화는 「값을 바깥으로」였다. 다음 장은 같은 자료를 안에서 어떻게 놓을지를 묻는다. 구조체를 배열로 늘어놓을 것인가, 필드마다 배열을 둘 것인가 — 다음 장은 이 선택 하나로 캐시 때문에 프로그램 속도가 몇 배 달라지는 것을 재어 본다.
주
- Protocol Buffers 문서 「Encoding」(protobuf.dev): 150 은
96 01, 와이어 타입이 길이를 알려 주므로 모르는 필드를 건너뛸 수 있다. ↩ - CVE-2022-37434(2022-08-05 공개) 기록. ↩
- I. Beer, S. Groß, 「A deep dive into an NSO zero-click iMessage exploit」, Google Project Zero, 2021-12-15. 수정은 iOS 14.8(2021-09-13). ↩
- CVE-2023-4863 기록, B. Hawkes, 「The WebP 0day」(blog.isosceles.com, 2023-09-21). 수정 커밋
902bc919. ↩ - CVE-2016-2108 기록. ↩
- P. Wang, J. Krinke, K. Lu, G. Li, S. Dodier-Lazaro, 「How Double-Fetch Situations turn into Double-Fetch Vulnerabilities: A Study of Double Fetches in the Linux Kernel」, USENIX Security 2017. ↩