Skip to content

Latest commit

 

History

History
339 lines (281 loc) · 20 KB

File metadata and controls

339 lines (281 loc) · 20 KB

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의 재실행(reexec)과 직렬화/역직렬화

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은 이 값을 문자열 자체로는 신뢰하지 않지만(코드로 실행하지 않으므로), 포맷(줄 구조)이 깨지지 않는다는 전제는 암묵적으로 신뢰하고 있었다.

sd_notify() / NotifyAccess와 상태 메시지

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)**이 원인이기 때문이다.

취약점 분석

패치 전 unit_deserialize() (systemd 239, unit.c)

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)보다 크다는 두 서브시스템 간의 크기 가정 불일치가 근본 원인이다.

공격이 성립하는 경로 (개념적 재구성)

  1. 공격자가 NotifyAccess가 허용된 서비스(예: 자신이 정의하거나 통제 가능한 Type=notify 유닛, 혹은 NotifyAccess=all로 설정된 유닛의 자식 프로세스)에서 sd_notify()STATUS=에 2048바이트를 넘는 텍스트를 실어 보낸다. 이 텍스트는 앞부분에 의미 없는 패딩을, 뒷부분에는 unit_deserialize()가 인식하는 형식의 key=value\n 문자열(예: 다른 필드를 가리키는 키)을 정확한 오프셋에 배치하도록 구성한다 — 정확한 오프셋 계산은 status-text= 접두어 길이와 cescape()의 이스케이프 규칙(제어 문자 없는 일반 텍스트라면 1:1 매핑)만 알면 공격자가 직접 할 수 있다.
  2. PID 1은 이 값을 status_text에 그대로 저장한다(당시에는 길이 제한이 없었다).
  3. daemon-reexec가 트리거되면(관리자의 유지보수 작업이거나, 특정 조건에서 attacker가 유도 가능한 경우도 배포판/구성에 따라 존재할 수 있다), manager_serialize()가 이 값을 unit_serialize_item_escaped()로 그대로 직렬화한다 — 길이 제한이 없으므로 2048바이트를 넘는 한 줄이 그대로 기록된다.
  4. 새로 실행된 systemd가 이 상태 파일을 unit_deserialize()로 읽는다. 문제의 긴 줄에서 fgets()가 2047바이트에서 멈추고 잘라낸다. 다음 fgets() 호출이 그 지점부터 이어 읽으면서, 공격자가 그 오프셋에 미리 심어 둔 문자열을 완전히 새로운 직렬화 줄로 파싱한다.
  5. 결과적으로 공격자가 통제하지 않아야 할 필드(다른 유닛 속성, 혹은 같은 유닛의 다른 상태 필드)가 공격자가 원하는 값으로 덮어써진 채 PID 1의 메모리 상태에 반영된다 — "재실행 전후로 시스템 상태가 그대로 유지된다"는 무결성 가정이 깨지는 지점이다.
[패치 전: 직렬화 파일 스트림 레이아웃 (개념도)]

오프셋 0                                    2047     2048
  │                                            │        │
  ▼                                            ▼        ▼
"status-text=<attacker 패딩...............",  |  "...남은 텍스트\nmain-pid=<attacker 조작값>\n"
  └──────────── fgets() 호출 #1 ────────────┘  └──────── fgets() 호출 #2 ────────┘
   (버퍼 2048바이트 한계에서 강제 절단,          (파서 입장에서는 "새 줄"로 보임 —
    실제 개행을 못 만난 채 반환)                  공격자가 배치한 key=value 그대로 파싱됨)

패딩 길이를 공격자가 정확히 계산해 조절하면, 2048번째 바이트 이후에 오는 내용을
"attacker가 원하는 임의의 key=value 줄"로 정밀 배치할 수 있다.

제약 조건

  • 공격이 성립하려면 공격자가 NotifyAccess가 허용된 서비스의 알림 소켓에 메시지를 보낼 수 있는 위치에 있어야 한다 — 즉 이미 어느 정도 로컬 권한(자신이 통제하는 서비스 실행)을 가진 상태에서의 권한 상승 시나리오다(CVSS AV: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()으로 바꾸면 그 잘림 자체가 사라진다.

참고 자료

공개 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가 공격자가 원하는 시점에 트리거될 수 있는 조건(예: 특정 배포판의 자동 업그레이드 정책, 혹은 공격자가 간접적으로 재실행을 유도할 수 있는 다른 경로가 있는지)은 별도로 조사하지 못했다.