systemd 재실행(reexec) 상태 역직렬화 라인 버퍼 오버런 — unit_deserialize()의 고정 LINE_MAX 대 알림 소켓 NOTIFY_BUFFER_MAX 불일치 (CVE-2018-15686)
| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2018-15686 |
| 영향 버전 | systemd 239 이하 |
| CVSS v3.1 | 7.8 (High) — AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CVSS v2 | 7.2 — AV:L/AC:L/Au:N/C:C/I:C/A:C |
| 공개일 | 2018-10-26 (NVD) |
| 발견자 | Jann Horn (Google) |
| 수정 커밋 | 8948b3415d762245ebf5e19d80b97d4d8cc208c1 ("core: when deserializing state always use read_line(…, LONG_LINE_MAX, …)") 외, PR #10519 |
| CWE | 명시적 CWE 태그는 NVD 레코드에 없음 (근본 원인은 버퍼 경계 처리 오류/신뢰 경계 혼동에 해당) |
systemd(PID 1)는 자기 자신을 업그레이드하거나 systemctl daemon-reexec가 호출되면,
현재 관리 중인 모든 유닛(서비스)의 상태를 텍스트 형식으로 메모리 파일디스크립터에
직렬화(serialize)한 뒤 새 systemd 바이너리를 execve()하고, 그 파일을 다시 읽어
상태를 복원(deserialize)한다. 이 복원 함수 unit_deserialize()는 한 줄씩
fgets(line, sizeof(line), f)로 읽는데, 이때 사용한 스택 버퍼 line[LINE_MAX]는
크기가 2048바이트로 고정돼 있었다. 반면 서비스가 sd_notify()로 보낼 수 있는
상태 메시지(STATUS=...)는 systemd의 알림 소켓이 허용하는 최대 크기인
NOTIFY_BUFFER_MAX(=PIPE_BUF, 리눅스에서 4096바이트)까지 받아들여지고, 그 값이
길이 제한 없이 그대로 status-text 필드로 직렬화됐다. 그 결과 NotifyAccess가
허용된 서비스를 통제하는 공격자가 2048바이트를 넘는 STATUS= 메시지를 보내면,
직렬화된 한 줄의 길이가 fgets()의 버퍼 한계를 넘어서고, 재실행 시 이 긴 줄이
정확히 그 한계에서 잘려 나머지 절반이 새로운 독립된 직렬화 줄로 재해석된다.
공격자는 자신이 완전히 통제하는 상태 텍스트의 내용으로 이 "잘린 뒤 다시 읽히는"
바이트열을 채워, 시스템이 신뢰하는 형식의 key=value 직렬화 줄(예: 다른 필드값)을
끼워 넣을 수 있다 — 서비스 하나의 상태 텍스트로 시작해, PID 1 전체 상태 파일 스트림의
파싱 흐름 자체를 조작하는 셈이다.
systemd PID 1은 시스템 전체의 서비스 상태 머신을 메모리에 들고 있는 장수(long-lived)
프로세스다. systemctl daemon-reexec나 systemd 자체의 업그레이드 시, 프로세스를
완전히 새로 띄우는 대신 자기 자신을 execve()로 재실행해 상태를 유지한다. 이때
"상태를 유지한다"는 것은 실제로는 다음과 같은 절차다.
[daemon-reexec 절차]
1. manager_serialize(): 모든 Unit(서비스/소켓/타이머 등)의 현재 상태를
텍스트 key=value 줄들로 memfd(또는 임시 파일)에 순서대로 기록
예) "type=service\nstate-change-timestamp=...\nstatus-text=\"...\"\n\n"
(빈 줄 하나가 한 유닛의 끝을 표시)
2. 해당 memfd의 fd 번호를 환경 변수(SYSTEMD_LOG_TARGET 등과 함께)로 자식에게 전달
3. execve("/usr/lib/systemd/systemd", ...) — PID 1 자신을 새 바이너리로 교체
4. 새로 시작된 systemd가 그 fd를 다시 열어 manager_deserialize() → 각 유닛에 대해
unit_deserialize() 호출 → 직렬화 때와 같은 텍스트를 한 줄씩 다시 파싱해 원래
상태(활성 상태, 메인 PID, cgroup, 상태 메시지 등)를 복원
이 메커니즘은 재실행 중에도 실행 중인 서비스들의 프로세스(cgroup, 소켓, 열린 fd
등)를 그대로 살려 두면서 PID 1의 자료구조만 새로 짜맞추기 위한 것이다. 문제는 이
직렬화 포맷이 신뢰 경계를 넘나든다는 점이다 — 직렬화되는 값 중 일부(status-text
등)는 서비스 프로세스가 보낸 메시지에서 유래하는데, 그 서비스는 attacker가
통제할 수도 있는 일반 권한 프로세스일 수 있다. PID 1은 이 값을 문자열 자체로는
신뢰하지 않지만(코드로 실행하지 않으므로), 포맷(줄 구조)이 깨지지 않는다는
전제는 암묵적으로 신뢰하고 있었다.
systemd 서비스는 sd_notify(0, "READY=1\nSTATUS=starting up\n")처럼 /run/systemd/notify
유닉스 도메인 소켓(datagram)에 KEY=VALUE 줄들을 담은 메시지를 보내 자신의 상태를
PID 1에 알릴 수 있다. STATUS= 태그는 사람이 읽는 자유 형식 상태 문자열로, 별도의
포맷 검증 없이 그대로 저장된다. 어떤 프로세스(PID)가 이 알림을 보낼 자격이 있는지는
유닛의 NotifyAccess= 설정(none/main/exec/all)이 정한다 — Type=notify 서비스는
기본적으로 main(메인 프로세스만 허용)이지만, 관리자가 all로 설정하거나 서비스가
포크한 자식이 메인 프로세스의 자격으로 소켓을 상속받는 구성이면 더 넓은 프로세스
집합이 임의의 STATUS= 문자열을 보낼 수 있다. 이 취약점은 "그 문자열이 앞으로도 계속
단순 표시용 문자열로만 남는다"는 가정이 재실행 경로에서 깨진다는 데서 시작한다.
PID 1이 이 소켓에서 한 번에 읽어들이는 최대 바이트 수는 manager.c에 다음과 같이
고정돼 있다.
/* manager.c, manager_dispatch_notify_fd() */
char buf[NOTIFY_BUFFER_MAX+1];
...
n = recvmsg(m->notify_fd, &msghdr, MSG_DONTWAIT|MSG_CMSG_CLOEXEC|MSG_TRUNC);그리고 NOTIFY_BUFFER_MAX는 (src/basic/def.h, systemd 239 기준):
#define NOTIFY_BUFFER_MAX PIPE_BUF리눅스에서 PIPE_BUF는 4096이므로, 서비스는 한 번의 sd_notify() 호출로 최대
약 4096바이트에 가까운 STATUS= 값을 PID 1에 전달할 수 있다.
status-text 같은 문자열 필드는 unit_serialize_item_escaped()로 기록된다
(unit.c, systemd 239 기준).
int unit_serialize_item_escaped(Unit *u, FILE *f, const char *key, const char *value) {
_cleanup_free_ char *c = NULL;
...
c = cescape(value);
...
fputs(key, f);
fputc('=', f);
fputs(c, f);
fputc('\n', f);
return 1;
}cescape()는 C 문자열 리터럴 스타일로 제어 문자·백슬래시·따옴표 등을 \n, \t,
\\ 같은 두 글자 이스케이프 시퀀스로 바꾼다 — 즉 값 안에 실제 개행 바이트(0x0a)가
그대로 섞여 들어가는 일은 없다. 이 사실이 중요한데, 이 취약점은 "값 안에 몰래
개행을 심어 줄을 하나 더 만든다"는 단순한 인젝션이 아니라, 아래에서 보듯 **줄 길이
자체가 파서의 고정 버퍼보다 길어서 발생하는 잘림(truncation)**이 원인이기 때문이다.
int unit_deserialize(Unit *u, FILE *f, FDSet *fds) {
...
for (;;) {
char line[LINE_MAX], *l, *v;
CGroupIPAccountingMetric m;
size_t k;
if (!fgets(line, sizeof(line), f)) {
if (feof(f))
return 0;
return -errno;
}
char_array_0(line);
l = strstrip(line);
/* End marker */
if (isempty(l))
break;
k = strcspn(l, "=");
...LINE_MAX는 POSIX가 요구하는 최소값이자 glibc가 실제로 채택한 값인 2048바이트다
(이 환경의 getconf LINE_MAX 결과도 2048로 일치함을 직접 확인했다). fgets()의
표준 동작은 다음과 같다 — 버퍼 크기(2048)를 채우거나 개행을 만나거나 EOF에
도달할 때까지 읽는다. 버퍼가 다 찼는데 아직 개행을 못 만났다면, fgets()는
지금까지 읽은 2047바이트(+NUL)만 반환하고, 스트림 커서는 그 다음 바이트에
그대로 남는다. 다음 fgets() 호출은 그 지점부터 다시 읽기 시작하며, 파서 입장에서는
이것이 완전히 새로운 "줄"이다.
status-text=라는 키 접두어(12바이트)를 감안하면, cescape()를 거친 STATUS= 값이
약 2035바이트를 넘으면 status-text=<value>\n 한 줄 전체가 2048바이트 한도를
초과한다. 앞서 확인했듯 NOTIFY_BUFFER_MAX(4096)는 이 한도보다 훨씬 크므로,
공격자가 통제하는 서비스는 이 조건을 손쉽게 만족하는 STATUS= 값을 보낼 수 있다 —
소켓 계층의 최대 허용 크기(4096)가 재실행 상태 파일의 줄 파서가 감당할 수 있는
최대 크기(2048)보다 크다는 두 서브시스템 간의 크기 가정 불일치가 근본 원인이다.
- 공격자가
NotifyAccess가 허용된 서비스(예: 자신이 정의하거나 통제 가능한Type=notify유닛, 혹은NotifyAccess=all로 설정된 유닛의 자식 프로세스)에서sd_notify()로STATUS=에 2048바이트를 넘는 텍스트를 실어 보낸다. 이 텍스트는 앞부분에 의미 없는 패딩을, 뒷부분에는unit_deserialize()가 인식하는 형식의key=value\n문자열(예: 다른 필드를 가리키는 키)을 정확한 오프셋에 배치하도록 구성한다 — 정확한 오프셋 계산은status-text=접두어 길이와cescape()의 이스케이프 규칙(제어 문자 없는 일반 텍스트라면 1:1 매핑)만 알면 공격자가 직접 할 수 있다. - PID 1은 이 값을
status_text에 그대로 저장한다(당시에는 길이 제한이 없었다). daemon-reexec가 트리거되면(관리자의 유지보수 작업이거나, 특정 조건에서 attacker가 유도 가능한 경우도 배포판/구성에 따라 존재할 수 있다),manager_serialize()가 이 값을unit_serialize_item_escaped()로 그대로 직렬화한다 — 길이 제한이 없으므로 2048바이트를 넘는 한 줄이 그대로 기록된다.- 새로 실행된 systemd가 이 상태 파일을
unit_deserialize()로 읽는다. 문제의 긴 줄에서fgets()가 2047바이트에서 멈추고 잘라낸다. 다음fgets()호출이 그 지점부터 이어 읽으면서, 공격자가 그 오프셋에 미리 심어 둔 문자열을 완전히 새로운 직렬화 줄로 파싱한다. - 결과적으로 공격자가 통제하지 않아야 할 필드(다른 유닛 속성, 혹은 같은 유닛의 다른 상태 필드)가 공격자가 원하는 값으로 덮어써진 채 PID 1의 메모리 상태에 반영된다 — "재실행 전후로 시스템 상태가 그대로 유지된다"는 무결성 가정이 깨지는 지점이다.
[패치 전: 직렬화 파일 스트림 레이아웃 (개념도)]
오프셋 0 2047 2048
│ │ │
▼ ▼ ▼
"status-text=<attacker 패딩...............", | "...남은 텍스트\nmain-pid=<attacker 조작값>\n"
└──────────── fgets() 호출 #1 ────────────┘ └──────── fgets() 호출 #2 ────────┘
(버퍼 2048바이트 한계에서 강제 절단, (파서 입장에서는 "새 줄"로 보임 —
실제 개행을 못 만난 채 반환) 공격자가 배치한 key=value 그대로 파싱됨)
패딩 길이를 공격자가 정확히 계산해 조절하면, 2048번째 바이트 이후에 오는 내용을
"attacker가 원하는 임의의 key=value 줄"로 정밀 배치할 수 있다.
- 공격이 성립하려면 공격자가
NotifyAccess가 허용된 서비스의 알림 소켓에 메시지를 보낼 수 있는 위치에 있어야 한다 — 즉 이미 어느 정도 로컬 권한(자신이 통제하는 서비스 실행)을 가진 상태에서의 권한 상승 시나리오다(CVSSAV:L/PR:L과 일치). daemon-reexec가 실제로 트리거되어야 효과가 나타난다. 관리자가 수동으로systemctl daemon-reexec를 실행하거나 systemd 자체를 업그레이드하는 시점에만 상태 직렬화/역직렬화 경로를 타므로, 공격자가 이 시점을 완전히 통제하지 못하는 구성에서는 악용 창이 제한적일 수 있다.
PR #10519는 여러 커밋으로 구성돼 있는데, 근본 원인과 직접 관련된 것은 다음 두 가지다.
(a) unit_deserialize()/unit_deserialize_skip()의 fgets() → read_line() 교체
(커밋 8948b3415d762245ebf5e19d80b97d4d8cc208c1, 커밋 메시지에 "Fixes a vulnerability
discovered by Jann Horn at Google. CVE-2018-15686"이라고 명시):
int unit_deserialize(Unit *u, FILE *f, FDSet *fds) {
...
for (;;) {
- char line[LINE_MAX], *l, *v;
+ _cleanup_free_ char *line = NULL;
CGroupIPAccountingMetric m;
+ char *l, *v;
size_t k;
- if (!fgets(line, sizeof(line), f)) {
- if (feof(f))
- return 0;
- return -errno;
- }
+ r = read_line(f, LONG_LINE_MAX, &line);
+ if (r < 0)
+ return log_error_errno(r, "Failed to read serialization line: %m");
+ if (r == 0) /* eof */
+ break;
- char_array_0(line);
l = strstrip(line);
-
- /* End marker */
- if (isempty(l))
+ if (isempty(l)) /* End marker */
break;read_line()은 고정 스택 버퍼 대신 필요한 만큼 malloc()으로 늘려 가며 한 줄
전체를 읽는 함수로, 상한(LONG_LINE_MAX, 수백 KB 단위로 훨씬 큼)을 넘는 줄만
명시적 오류로 처리한다. 즉 "줄이 버퍼보다 길면 조용히 두 줄로 쪼개진다"는 잘림
동작 자체를 없애고, "줄이 비정상적으로 길면 그 자체를 오류로 취급해 재실행을
중단한다"로 바꿨다.
(b) 저장 단계에서 상태 텍스트 길이를 원천적으로 제한
(커밋 3eac1bcae9284fb8b18f4b82156c0e85ddb004e5, "core: enforce a limit on STATUS=
texts recvd from services"):
static void service_notify_message(...) {
_cleanup_free_ char *t = NULL;
if (!isempty(e)) {
- if (!utf8_is_valid(e))
- log_unit_warning(u, "Status message in notification message is not UTF-8 clean.");
+ /* Note that this size limit check is mostly paranoia: since the datagram size we are willing
+ * to process is already limited to NOTIFY_BUFFER_MAX, this limit here should never be hit. */
+ if (strlen(e) > STATUS_TEXT_MAX)
+ log_unit_warning(u, "Status message overly long (%zu > %u), ignoring.", strlen(e), STATUS_TEXT_MAX);
+ else if (!utf8_is_valid(e))
+ log_unit_warning(u, "Status message in notification message is not UTF-8 clean, ignoring.");
else {
t = strdup(e);여기서 새로 정의된 STATUS_TEXT_MAX는 16KB(16U*1024U)로, NOTIFY_BUFFER_MAX(4096)보다
넉넉히 크게 잡아 정상적인 상태 메시지는 절대 걸리지 않게 하면서, 이 검사 자체가
"파서가 감당 못 하는 줄이 만들어지지 않게 애초에 저장을 막는" 방어선 역할을 한다.
커밋 메시지의 표현대로 (a)가 근본 수정이고 (b)는 "이중 방어"(defense in depth)다.
[패치 후: 직렬화 파일 스트림 레이아웃 (개념도)]
read_line(f, LONG_LINE_MAX, &line)
│
▼
"status-text=<attacker 패딩....................................................\n"
└───────────────────── 한 줄 전체를 한 번에 읽음 (malloc으로 필요한 만큼 확장) ─────┘
│
▼
줄 안에 심어 둔 "가짜 key=value"는 여전히 status-text 값의 일부(이스케이프된 문자열
내용)일 뿐, 별도의 줄로 재해석되지 않음 — cescape()가 이미 개행을 이스케이프해
두었으므로 파서는 이걸 하나의 값으로만 취급한다.
(추가 방어선) service_notify_message()에서 STATUS= 값이 16KB를 넘으면
애초에 저장을 거부 → status-text가 비정상적으로 길어질 경우 자체가 원천 차단.
패치 전/후의 차이는 "긴 한 줄을 어떻게 읽느냐"로 요약된다 — 고정 크기 버퍼로 잘라
읽으면 잘린 나머지가 별도의 신뢰된 입력으로 재해석되는 구조적 위험이 있었고, 가변
길이 read_line()으로 바꾸면 그 잘림 자체가 사라진다.
- CVE-2018-15686 — NVD
- 수정 PR #10519 "pid1 serialization/deserialization fixes" — systemd/systemd
- 핵심 수정 커밋 8948b3415d762245ebf5e19d80b97d4d8cc208c1 — "core: when deserializing state always use read_line(…, LONG_LINE_MAX, …)" (커밋 메시지에 CVE-2018-15686·Jann Horn 명시)
- 보조 수정 커밋 3eac1bcae9284fb8b18f4b82156c0e85ddb004e5 — "core: enforce a limit on STATUS= texts recvd from services"
- 패치 전 소스 unit.c (systemd v239 태그) — GitHub
- 패치 전 소스 service.c (systemd v239 태그) — GitHub
- 패치 전 소스 manager.c의 NOTIFY_BUFFER_MAX 사용부 (systemd v239 태그) — GitHub
sd_notify(3)man page — systemd 공식 문서- Ubuntu Security — CVE-2018-15686
공개 PoC로는 Exploit-DB에 등록된 항목(EDB-ID 45714)이 검색으로 확인되었으나
(미검증, 실행하지 않음), 이 리포트의 공격 경로 재구성은 해당 PoC가 아니라
위에 나열한 실제 소스 diff와 상수 정의(LINE_MAX, NOTIFY_BUFFER_MAX,
STATUS_TEXT_MAX)를 직접 대조해 도출한 것이다.
- 이 리포트는 "긴
STATUS=값이 재실행 상태 파일에서 줄 경계를 깨뜨려 임의의key=value를 주입할 수 있다"는 주입 메커니즘 자체는 실제 소스 diff와 버퍼 크기 상수 대조로 확인했지만, 그렇게 주입된 필드를 구체적으로 어떻게 조합해야 root 권한 상승까지 이어지는지(예: 어떤 유닛의 어떤 필드를 노려야 하는지,main-pid스푸핑인지 cgroup 관련 필드인지 등) 상세 익스플로잇 체인은 Jann Horn의 원 보고서(Google 내부/비공개 채널로 보고된 것으로 보이며, 공개된 전체 기술 보고서를 찾지 못함)에 의존해야 하는데 이 문서 작성 시점에는 그 원문을 확보하지 못했다. 따라서 "권한 상승까지 이어지는 정확한 필드 조합"은 확인하지 못한 상태로 남겨 둔다. daemon-reexec가 공격자가 원하는 시점에 트리거될 수 있는 조건(예: 특정 배포판의 자동 업그레이드 정책, 혹은 공격자가 간접적으로 재실행을 유도할 수 있는 다른 경로가 있는지)은 별도로 조사하지 못했다.