| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2018-16865 |
| 영향 소프트웨어 | systemd (v240 이하), systemd-journald / systemd-journal-remote |
| CWE | 스택 소진에 따른 스택-힙 충돌(Stack Clash류); CVE 설명 기준 "제한 없는 메모리 할당" |
| CVSS v3.1 | 7.8 (NVD 기준, AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — systemd-journal-remote 사용 시 네트워크 공격면(CVSS v3.0 7.5, AV:N/AC:H) |
| 공개일 | 2019-01-09 (Qualys Research Labs 조율 공개, CVE-2018-16864/16865/16866 동시 발표) |
| 발견자 | Qualys Research Labs |
| 수정 커밋 | systemd 052c57f132f04a3cf4148f87561618da1a6908b4 ("journald: set a limit on the number of fields (1k)") |
systemd-journald는 로그를 기록하려는 프로세스가 /run/systemd/journal/socket에 자신만의
"네이티브(native)" 프로토콜로 로그 항목(entry)을 보내면 이를 받아 디스크에 저장하는
데몬이다. 하나의 로그 항목은 MESSAGE=..., PRIORITY=...처럼 여러 개의 KEY=VALUE
필드로 구성되는데, 이 필드들을 파싱하는 코드는 메시지 전체 바이트 크기(최대 약 770MB)만
검사할 뿐 필드의 개수에는 어떤 상한도 두지 않았다. 필드 하나가 최소 3바이트("A=\n")로
표현 가능하기 때문에, 공격자는 770MB짜리 메시지 하나에 필드를 최대 수억 개까지 욱여넣을
수 있었다. 이렇게 파싱된 필드 배열은 이후 디스크에 기록하기 직전 단계에서
journal_file_append_entry()가 필드 개수에 정확히 비례하는 크기를 힙이 아니라 스택에
alloca()로 할당하는 데 쓰인다. 필드 하나당 16바이트(EntryItem 구조체 크기)이므로
최악의 경우 이 스택 할당 요청 크기는 약 4GB까지 치솟을 수 있었고, 이는 "Stack Clash"라고
불리는 공격 기법(고의로 스택을 다른 메모리 영역과 충돌시켜 그 영역을 덮어쓰는 기법)으로
journald 프로세스(대개 root 권한으로 실행)에서 서비스 거부 또는 임의 코드 실행까지 이어질
수 있는 근본 원인이 됐다.
systemd-journald로 로그를 남기는 방법은 여러 가지가 있는데(syslog() 호환 소켓,
/dev/kmsg 읽기, stdout/stderr 캡처 등), 그중 가장 풍부한 표현이 가능한 것이
sd_journal_send() API가 쓰는 "네이티브" 프로토콜이다. 이 프로토콜은 하나의 로그 항목을
줄바꿈(\n)으로 구분된 여러 필드의 나열로 표현한다:
MESSAGE=disk quota exceeded
PRIORITY=3
_UID=1000
각 필드는 두 가지 형태 중 하나다.
- 텍스트 필드:
KEY=VALUE\n형태로, 줄 안에=가 있으면 그 줄 전체를 한 필드로 취급한다. - 바이너리 필드: 값에 개행 문자가 섞여 있는 등 텍스트로 표현하기 곤란할 때 쓰는
형식으로,
KEY\n다음에 8바이트 리틀엔디안 길이(l), 그 뒤에l바이트의 원본 값, 마지막에\n이 온다.
이 두 형태 모두 파서(server_process_entry(), src/journal/journald-native.c)를 거치며,
파서는 필드 하나를 발견할 때마다 struct iovec 하나를 만들어 동적 배열 iovec[]에
추가한다. struct iovec는 POSIX의 scatter-gather I/O용 구조체로, "이 필드의 데이터가
메모리 어디(iov_base)에 몇 바이트(iov_len)로 있는지"만 가리키는 가벼운 포인터+길이
쌍이다. 이 배열은 GREEDY_REALLOC(필요할 때마다 여유 있게 두 배씩 늘려 재할당하는
매크로)으로 힙에 관리되므로, 필드 개수 자체가 늘어나는 것은 이 단계에서는 별문제가
되지 않는다 — 문제는 이 배열이 다음 단계로 넘어갈 때 생긴다.
/* src/journal/journald-native.c, server_process_entry() 파싱 루프 (일부) */
while (*remaining > 0) {
...
/* n existing properties, 1 new, +1 for _TRANSPORT */
if (!GREEDY_REALLOC(iovec, m,
n + 2 + N_IOVEC_META_FIELDS + N_IOVEC_OBJECT_FIELDS +
client_context_extra_fields_n_iovec(context))) { ... }
q = memchr(p, '=', e - p);
if (q) { /* 텍스트 KEY=VALUE 필드 */
if (journal_field_valid(p, q - p, false)) {
size_t l = e - p;
iovec[n++] = IOVEC_MAKE((char*) p, l);
entry_size += l;
...
}
} else { /* 바이너리 필드 */
uint64_t l = unaligned_read_le64(e + 1);
if (l > DATA_SIZE_MAX) { ... break; } /* 필드 하나의 크기만 개별 검사 */
...
iovec[n] = IOVEC_MAKE(k, (e - p) + 1 + l);
entry_size += iovec[n].iov_len;
n++;
}
}
/* 파싱이 모두 끝난 뒤에야 총 바이트 수만 검사 */
if (entry_size + n + 1 > ENTRY_SIZE_MAX) {
log_debug("Entry is too big with %zu properties and %zu bytes, ignoring.", n, entry_size);
goto finish;
}당시 소스에서 ENTRY_SIZE_MAX는 770MB(1024*1024*770u), 개별 바이너리 필드의 상한인
DATA_SIZE_MAX는 768MB(1024*1024*768u)로 정의돼 있었다(src/shared/journal-importer.h).
즉 검사되는 것은 오직 "총 바이트 수"와 "필드 하나의 최대 크기"뿐이고, "필드가 몇
개인가"는 어디에서도 세지 않는다.
malloc()이 힙에서 메모리를 얻어와 명시적으로 free()할 때까지 유지하는 것과 달리,
alloca()는 현재 함수의 스택 프레임 위에 직접 메모리를 할당한다. 함수가 리턴하면
스택 포인터가 원래대로 돌아가면서 자동으로 회수되므로 free()가 필요 없고, 작은 임시
버퍼에는 malloc보다 빠르다는 장점이 있다. 하지만 이 설계에는 근본적인 위험이 있다 —
malloc()은 실패하면 NULL을 반환해 호출자가 확인하고 대응할 기회를 주지만,
alloca()는 요청한 크기가 남은 스택 공간보다 크면 확인할 방법 없이 곧바로 스택
가드 페이지를 뚫고 지나가 버린다. 리눅스에서 스택은 보통 8MB 정도로 제한되고, 그 바로
아래(낮은 주소 방향)에는 프로세스의 다른 메모리 영역(힙, mmap()으로 매핑된 라이브러리나
버퍼 등)이 이어져 있는 경우가 흔하다. alloca()로 요청한 크기가 스택에 남은 여유보다
훨씬 크면, 스택 포인터가 이 여유 공간을 건너뛰어 곧바로 그 다른 메모리 영역 한복판에
꽂혀버릴 수 있다 — 이것이 "Stack Clash"(스택 충돌) 공격 기법의 첫 단계인 "Jump"다. 그
직후 그 자리에 실제로 값을 채워 넣는("Smash") 코드가 실행되면, 원래 스택이 아니라 힙이나
mmap 영역의 데이터가 공격자가 원하는 값으로 덮어써진다.
systemd의 소스 곳곳에는 alloca()를 직접 쓰는 대신 newa()라는 매크로가 쓰이는데, 이는
단순히 타입 정보를 붙여 alloca()를 감싼 것뿐이다.
/* src/basic/alloc-util.h */
#define newa(t, n) \
({ \
assert(!size_multiply_overflow(sizeof(t), n)); \
(t*) alloca(sizeof(t)*(n)); \
})즉 newa(EntryItem, n)은 곱셈 오버플로만 검사할 뿐, 결과 크기가 남은 스택보다 큰지는
전혀 검사하지 않는다.
파싱이 끝나면 server_dispatch_message()를 거쳐 결국
journal_file_append_entry()(src/journal/journal-file.c)가 호출된다. 이 함수는 파싱
단계에서 만들어진 iovec[] 배열(필드 개수 n_iovec개)을 디스크 저장용 포맷으로 바꾸기
위해, 필드마다 하나씩 대응하는 EntryItem(디스크에 적힐 오프셋+해시 쌍, 8바이트+8바이트로
16바이트) 배열을 준비한다.
/* src/journal/journal-file.c, journal_file_append_entry() */
int journal_file_append_entry(
JournalFile *f, const dual_timestamp *ts, const sd_id128_t *boot_id,
const struct iovec iovec[], unsigned n_iovec,
uint64_t *seqnum, Object **ret, uint64_t *offset) {
EntryItem *items;
...
/* alloca() can't take 0, hence let's allocate at least one */
items = newa(EntryItem, MAX(1u, n_iovec)); /* <-- 필드 개수(n_iovec)에 비례하는 스택 할당 */
for (i = 0; i < n_iovec; i++) {
Object *o;
uint64_t p;
r = journal_file_append_data(f, iovec[i].iov_base, iovec[i].iov_len, &o, &p);
...
items[i].object_offset = htole64(p);
items[i].hash = o->data.hash;
}
...
}/* src/journal/journal-def.h */
struct EntryItem {
le64_t object_offset;
le64_t hash;
} _packed_; /* sizeof(EntryItem) == 16 */여기서 n_iovec은 앞서 파싱 단계에서 필드 개수 상한 없이 늘어난 바로 그 n이다.
파싱 단계가 검사하는 것은 "필드들의 총 바이트 합이 770MB를 넘지 않는가"뿐이었으므로,
공격자는 필드 하나를 최소 3바이트("A=\n": 이름 1글자 + = + 개행)로 채워 총 바이트
예산을 최대한 잘게 쪼개는 것으로 필드 개수를 극대화할 수 있다.
770,000,000 bytes(≈ ENTRY_SIZE_MAX) ÷ 3 bytes/필드 ≈ 약 2.5억 개 필드
2.5억 개 × 16 bytes(sizeof(EntryItem)) ≈ 약 4GB
즉 파싱 단계에서 입력으로 준 데이터는 770MB 남짓이었지만, 그 필드 개수를 그대로 이어받아
alloca()하는 items 배열은 16/3 ≈ 5.3배로 증폭돼 최대 약 4GB에 달한다 — 이는 보통
8MB인 스택 크기를 압도적으로 초과하는 크기이며, 스택 가드 페이지를 건너뛰어 곧장 인접한
mmap 매핑 영역까지 침범하기에 충분한 크기다. 실제 Qualys 권고문은 이 배열이 "journald의
메인 스택에서 뛰어올라 mmap 영역까지 도달하기에 충분히 크다(amd64에서도)"고 설명한다.
[공격자가 보내는 770MB짜리 native 메시지]
"A=\nB=\nC=\n ... (약 2.5억 개의 최소 필드) ..."
│
▼ server_process_entry() 파싱 루프 (필드 개수 상한 없음)
iovec[] (힙, GREEDY_REALLOC로 크기 자동 증가) n = 약 2.5억
│
▼ journal_file_append_entry(iovec, n_iovec=n)
┌─────────────────────────── journald 스택 ───────────────────────────┐
│ ... 호출자 프레임들 ... │
│ items = newa(EntryItem, n_iovec) <- 크기 검사 없는 alloca() │
│ n_iovec × 16 bytes ≈ 4GB 요청 │
└──────────────────────────────────┬───────────────────────────────┘
│ 스택은 보통 8MB, 4GB 요청은 가드 페이지를
│ 훌쩍 넘어 "Jump" — 스택 포인터가 곧바로
▼ 인접한 다른 매핑으로 이동
+--------------------------+
| mmap() 영역 (다른 매핑, | <- 겹치는 지점
| 예: journald의 다른 버퍼) |
+--------------------------+
│
▼ 이어지는 for 루프가 items[i]에
기록("Smash")하면서 이 영역이
공격자가 유도한 값으로 오염됨
흥미롭게도 이 수정을 작성한 systemd 메인테이너는 커밋 메시지에서 CVE 설명과는 다른 관점을 남겼다.
"What from I can see, the problem is not from an alloca, despite what the CVE description says, but from the attack multiplication that comes from creating many very small iovecs: (void* + size_t) for each three bytes of input message."
즉 메인테이너의 시각으로는 근본 원인이 "alloca() 자체"라기보다, 필드 하나당 3바이트
입력이 iovec(포인터+길이, 64비트 환경에서 16바이트) 하나로 부풀려지는 "증폭" 쪽에
가깝다는 것이다. 다만 이 증폭된 iovec 개수가 그대로 n_iovec으로 전달되어
journal_file_append_entry()의 alloca() 크기를 결정하므로, 두 관점은 서로 배치되지
않는다 — "필드 개수 증폭"이 원인이고, 그 원인이 실제로 위험해지는 지점이 검사 없는
alloca()라는 점에서 두 설명은 공격 경로의 앞과 뒤를 각각 가리키고 있다.
수정 커밋 052c57f는
journal_file_append_entry()의 alloca() 자체를 건드리는 대신, 그보다 훨씬 앞
단계인 파싱 루프에서 필드 개수 자체에 상한을 두는 방식을 택했다.
--- a/src/shared/journal-importer.h
+++ b/src/shared/journal-importer.h
@@ -21,6 +21,9 @@
#endif
#define LINE_CHUNK 8*1024u
+/* The maximum number of fields in an entry */
+#define ENTRY_FIELD_COUNT_MAX 1024
+
struct iovec_wrapper {--- a/src/journal/journald-native.c
+++ b/src/journal/journald-native.c
@@ -141,6 +141,11 @@ static int server_process_entry(
}
/* A property follows */
+ if (n > ENTRY_FIELD_COUNT_MAX) {
+ log_debug("Received an entry that has more than " STRINGIFY(ENTRY_FIELD_COUNT_MAX) " fields, ignoring entry.");
+ r = 1;
+ goto finish;
+ }
/* n existing properties, 1 new, +1 for _TRANSPORT */
if (!GREEDY_REALLOC(iovec, m, ...이 검사는 매 필드를 처리하기 직전, 파싱 루프 안에 삽입됐다. 필드 개수 n이 1024를
넘어서는 순간 해당 메시지 전체를 폐기(goto finish)하므로, 이후 이 메시지가
journal_file_append_entry()까지 도달하더라도 n_iovec은 최대 1024를 넘을 수
없다. 그 결과 alloca() 요청 크기의 상한도 함께 확정된다.
1024개 필드 × 16 bytes(EntryItem) = 16KB
16KB는 일반적인 8MB 스택에서 무시할 만한 크기이므로, 더 이상 스택 가드 페이지를 넘어 다른 메모리 영역과 충돌할 수 없다.
[공격자가 여전히 770MB짜리 native 메시지, 2.5억 개의 최소 필드로 시도]
│
▼ server_process_entry() 파싱 루프
n = 1 → 2 → 3 → ... → 1024 → 1025?
│
▼ n > ENTRY_FIELD_COUNT_MAX(1024) 검사에 걸림
log_debug(...); r = 1; goto finish;
│
▼
메시지 전체가 폐기됨 — journal_file_append_entry()에는
아예 도달하지 않음
[정상적인 소수 필드 메시지라면]
│
▼ n ≤ 1024 상태로 journal_file_append_entry(iovec, n_iovec=n) 호출
┌─────────────── journald 스택 ───────────────┐
│ items = newa(EntryItem, n_iovec) │
│ n_iovec × 16 bytes ≤ 16KB 요청 │ <- 8MB 스택 안에서 무해
└───────────────────────────────────────────────┘
이 수정은 journal_file_append_entry()가 여전히 alloca()를 쓴다는 사실 자체는 바꾸지
않았다 — 대신 그 함수에 도달할 수 있는 n_iovec의 최댓값을 애초에 작게 못박아, 검사 없는
alloca()가 위험해질 조건 자체를 제거하는 접근이다. 이 패치는 systemd v241에 포함됐고,
Red Hat·Debian·Ubuntu·Gentoo 등 주요 배포판이 각자의 안정 버전에 백포트했다(참고 자료의
배포판별 권고 참조).
- NVD CVE-2018-16865 — 공식 CVE 설명, CVSS. (검증됨)
- Qualys Research Labs — "System Down: A systemd-journald exploit" — CVE-2018-16864/16865/16866 최초 발견·기술 분석 원문(alloca 크기 산출, Stack Clash Jump/Smash 단계 설명). (검증됨, 1차 자료)
- 수정 커밋 052c57f — "journald: set a limit on the number of fields (1k)", systemd/systemd — 실제 패치 diff와 메인테이너의 근본 원인 해석 확인. (검증됨, 1차 자료)
- systemd
src/journal/journald-native.c(패치 직전 버전,f0136e0) —server_process_entry()파싱 루프 실제 소스 확인. (검증됨, 1차 자료) - systemd
src/journal/journal-file.c(패치 직전 버전,f0136e0) —journal_file_append_entry()의newa(EntryItem, ...)호출 확인. (검증됨, 1차 자료) - systemd
src/basic/alloc-util.h(패치 직전 버전) —newa()매크로가alloca()를 감싼 것임을 확인. (검증됨, 1차 자료) - systemd
src/shared/journal-importer.h(패치 직전 버전) —ENTRY_SIZE_MAX(770MB),DATA_SIZE_MAX(768MB) 상수 확인. (검증됨, 1차 자료) - Red Hat Bugzilla — CVE-2018-16865 — 배포판 대응 이력. (검증됨)
- Debian Security Advisory DSA-4367, Ubuntu USN-3855-1, Gentoo GLSA 201903-07 — 배포판별 백포트 확인. (검증됨)
- Stack Clash 공격의 마지막 단계(스택-인접 메모리를 덮어써 실제로 임의 코드 실행까지
이어지는 구체적 익스플로잇 체인, 예:
mmap영역에 어떤 데이터가 위치해야 어떤 방식으로 제어 흐름을 탈취할 수 있는지)는 Qualys 권고문에서도 개념 증명 코드 없이 기법 설명 수준으로만 공개했고, 이 리포트도 정적 분석 범위(파싱 로직과 패치의 근본 원인)에 집중해 그 이상의 익스플로잇 세부사항은 재현·검증하지 않았다. - CVE-2018-16865는 CVE-2018-16864(경쟁 조건을 이용한 별도의 무제한
alloca()), CVE-2018-16866(ASLR 우회용 정보 유출)와 함께 하나의 공격 체인으로 묶여 발표됐으나, 이 리포트는 CVE-2018-16865 단독의 근본 원인(필드 개수 증폭 → 무제한 스택 할당)에 집중했고 나머지 두 CVE의 세부 메커니즘은 다루지 않았다.