본문으로 건너뛰기

"커널" 태그로 연결된 3개 게시물개의 게시물이 있습니다.

모든 태그 보기

cgroup v2 입문

· 약 6분

docker run --memory=512m을 실행할 때, Kubernetes 매니페스트에 resources.limits.memory를 적을 때, systemd 유닛에 MemoryMax=를 넣을 때. 이 셋이 결국 같은 커널 기능 하나로 내려간다는 걸 알고 계셨나요. 컨테이너 시대의 자원 통제는 전부 cgroup 위에 서 있어요.

systemd 유닛 11종을 돌아본 글에서 cgroup을 여러 번 스쳐 지나갔는데, 정작 cgroup 자체를 정면으로 다룬 적이 없었습니다. 이번에 채웁니다.

cgroup은 무엇인가

cgroup(control group)은 프로세스들을 무리(group)로 묶고, 그 무리 단위로 자원을 통제하고 계량하는 커널 기능입니다. 하는 일은 세 가지로 요약됩니다.

기능내용
제한 (limit)무리가 쓸 수 있는 자원의 상한메모리 64MB까지, CPU 절반까지
계량 (accounting)무리가 지금까지 쓴 자원 측정누적 CPU 시간, 현재 메모리 사용량
통제 (control)무리 전체를 한 번에 조작일시 정지(freeze), 일괄 종료

핵심은 대상이 프로세스 하나가 아니라 무리라는 점입니다. ulimit 같은 전통 도구는 프로세스 단위라서, 프로세스가 fork하면 통제를 벗어납니다. cgroup은 자식 프로세스가 부모의 cgroup을 물려받으므로 fork로 도망칠 수 없습니다. 컨테이너가 "안에서 뭘 몇 개 띄우든 전체 512MB"라는 약속을 지킬 수 있는 이유입니다.

구현 형태는 파일시스템입니다. /sys/fs/cgroup 아래 디렉터리 하나가 cgroup 하나이고, 디렉터리를 만들면 cgroup이 생기고, 그 안의 파일에 값을 쓰면 제한이 걸립니다. 시스템 콜 API가 아니라 mkdirecho로 조작하는 커널 기능이라는 점이 cgroup의 성격을 잘 보여줍니다.

계보를 짧게 짚으면, 2006년 Google 엔지니어들이 "process containers"라는 이름으로 시작해 2008년 커널 2.6.24에 cgroup이라는 이름으로 병합됐습니다. 이후 LXC와 Docker가 이 위에 컨테이너를 지었고, systemd는 서비스 관리의 뼈대로 삼았습니다.

v1의 혼돈, v2의 단일 트리

cgroup을 검색하면 v1과 v2 이야기가 반드시 나옵니다. 이 구분을 모르면 검색 결과의 절반이 내 시스템과 안 맞는 이유를 알 수 없습니다.

v1의 설계는 유연함이 지나쳤습니다. 컨트롤러(memory, cpu, blkio 등)마다 각자 독립된 트리를 가질 수 있었습니다. 한 프로세스가 memory 트리에서는 A 그룹, cpu 트리에서는 B 그룹에 속하는 것이 가능했다는 뜻입니다. 표현력은 높지만 대가가 컸습니다. 트리끼리 조합이 어긋나면 동작을 예측하기 어렵고, 컨트롤러마다 인터페이스 관례가 제각각이었으며, 권한 위임 규칙도 정리되어 있지 않았습니다.

v2는 2016년 커널 4.5에서 이 혼돈을 정리했습니다. 원칙은 하나입니다. 트리는 하나, 모든 컨트롤러는 그 트리 위에서 동작한다(unified hierarchy).

인터페이스도 통일됐습니다. 어느 cgroup 디렉터리든 같은 규칙의 파일들이 있습니다.

  • cgroup.procs: 이 무리에 속한 PID 목록. 여기에 PID를 쓰면 편입
  • cgroup.controllers: 이 지점에서 쓸 수 있는 컨트롤러
  • memory.max, cpu.max, io.max, pids.max: 컨트롤러별 상한
  • memory.events: OOM 발생 횟수 같은 사건 카운터

v2에만 있는 물건도 생겼습니다. PSI(Pressure Stall Information)입니다. memory.pressure, cpu.pressure, io.pressure 파일이 "이 무리의 프로세스들이 자원을 못 받아 지연된 시간의 비율"을 보여줍니다. 사용량이 아니라 부족으로 인한 고통을 직접 재는 지표라서, 사용률 그래프보다 정직할 때가 많습니다.

전환은 배포판 기본값으로 이미 끝난 이야기입니다. Fedora 31(2019)을 시작으로 Ubuntu 21.10, Debian 11, RHEL 9가 v2를 기본으로 켰고, Docker는 20.10부터, Kubernetes는 1.25에서 v2 지원을 GA로 올렸습니다. 지금 새로 만나는 시스템은 거의 v2라고 봐도 됩니다. 내 시스템 확인은 한 줄입니다.

stat -fc %T /sys/fs/cgroup/
# cgroup2fs 면 v2, tmpfs 면 v1

실험: 64MB 국경에 100MB를 밀어 넣으면

개념은 여기까지 하고, 직접 확인해 보겠습니다. macOS라서 Docker 컨테이너(Linux VM) 안에서 실험했습니다. 컨테이너 자체가 cgroup으로 만들어지니 오히려 좋은 교보재입니다.

먼저 --memory=64m이 정말 cgroup 파일로 내려가는지 확인합니다.

$ docker run --rm --memory=64m alpine cat /sys/fs/cgroup/memory.max
67108864

67108864 바이트, 정확히 64MiB입니다. Docker 플래그는 커널에 도달하면 memory.max 파일의 숫자 하나가 됩니다. 이제 이 국경 안에서 100MB를 할당해 봅니다.

$ docker run --rm --memory=64m alpine sh -c 'head -c 100m /dev/zero | tail'
Killed
# 종료 코드 137 (128 + SIGKILL 9)

tail은 입력을 메모리에 쌓는 성질이 있어 간단한 메모리 포식자로 쓸 수 있습니다. 64MiB 상한을 넘는 순간 커널 OOM killer가 그 cgroup 안에서 희생자를 골라 SIGKILL을 보냅니다. 시그널 글에서 다룬 137이라는 종료 코드가 여기서 나옵니다.

죽인 흔적은 사건 카운터에 남습니다. 같은 컨테이너에서 OOM을 겪은 직후 memory.events를 읽으면 이렇습니다.

$ cat /sys/fs/cgroup/memory.events
low 0
high 0
max 72
oom 3
oom_kill 1
oom_group_kill 0

$ cat /sys/fs/cgroup/memory.peak
67108864

읽는 법이 흥미롭습니다. max 72는 사용량이 상한선에 부딪힌 횟수입니다. 부딪힐 때마다 커널은 먼저 page 회수(reclaim)로 버텨 봅니다. 그래도 안 되면 oom(3회)으로 넘어가고, 최종적으로 프로세스 하나를 죽였습니다(oom_kill 1). memory.peak가 정확히 67108864, 즉 상한과 같다는 것은 국경까지 꽉 채우고 넘지 못했다는 증거입니다. 상한 초과가 아니라 상한 도달 후 처형이라는 순서가 파일에 그대로 남습니다.

컨테이너 없이 맨 리눅스에서 같은 실험을 하려면 systemd가 지름길입니다.

# 임시 scope 유닛으로 감싸서 실행, 상한 64M
systemd-run --scope -p MemoryMax=64M some-command

# 서비스라면 유닛 파일에
[Service]
MemoryMax=64M

systemd 유닛은 각자 cgroup 하나씩을 차지하므로, MemoryMax=는 결국 그 cgroup의 memory.max에 값을 쓰는 것입니다. 트리 전체는 systemd-cgls로, 무리별 실시간 사용량은 systemd-cgtop으로 봅니다.

운영자가 기억할 세 가지

첫째, 모든 상위 도구의 제한은 cgroup 파일로 환원됩니다. Docker 플래그든, Kubernetes limits든, systemd 지시자든, 디버깅의 종착지는 /sys/fs/cgroup 아래 파일입니다. 제한이 이상하게 동작하면 도구의 문서보다 그 파일의 실제 값을 먼저 확인하는 쪽이 빠릅니다.

둘째, cgroup의 메모리 계정에는 page cache가 포함됩니다. memory.max는 프로세스의 anonymous 메모리만 세는 것이 아니라, 그 무리가 일으킨 파일 캐시까지 셉니다. 파일을 많이 읽는 워크로드(백업, 대량 스캔, 그리고 데이터베이스)가 "실제 사용량은 얼마 안 되는데" OOM에 몰리는 전형적인 이유입니다. 캐시는 압박이 오면 회수되므로 보통은 문제가 없지만, 회수 속도가 할당 속도를 못 따라가는 순간이 있습니다. 컨테이너 위 PostgreSQL의 메모리 제한 설정은 이 지점이 본론이라, 각도를 잡아 후속 글에서 따로 다루겠습니다.

셋째, v2의 사건 카운터와 PSI를 감시 지표로 쓸 수 있습니다. memory.eventsmaxoom_kill, 그리고 memory.pressure는 "제한에 얼마나 자주 부딪히고 있는가"를 알려주는 조기 경보입니다. OOM으로 죽고 나서 아는 것과, max 카운터가 오르는 걸 보고 미리 아는 것의 차이입니다.

여담 하나로 마무리할게요. 이 글을 쓰게 된 계기는 Claude Code가 Bash 명령에 cgroup 메모리 제한을 거는 옵션을 넣었다는 소식이었어요. AI 에이전트가 실행하는 명령의 폭주를 막는 안전장치로 2008년의 커널 기능이 다시 불려 나오는 것, cgroup이 여전히 현역 국경 수비대라는 좋은 증거 아닐까요.

참고 자료

DirtyDecrypt root LPE

· 약 7분

요점부터

Linux 커널 RxGK(rxrpc의 GSS 보안 계층) 디코드 경로에 COW(Copy-on-Write) 가드가 빠져 있어요. 비특권 사용자가 보낸 skb의 복호화 결과가 페이지 캐시의 공유 페이지에 그대로 쓰여요. 그 자리가 /etc/shadow, /etc/sudoers, SUID 바이너리의 페이지라면, 평범한 사용자가 root가 돼요.

별명은 DirtyDecrypt입니다. CVE는 CVE-2026-31635로 묶였지만, NVD에는 CVSS 3.1 7.5 High DoS로만 올라 있습니다. 발견자(Zellic + V12 보안팀)가 PoC로 보여준 실제 영향은 root LPE입니다. 2026-05-18에 PoC가 공개됐고, PoC repo는 GitHub에 그대로 있습니다. 즉시 악용 가능한 상태입니다.

CVE 한 줄, 함수 한 줄, 영향 두 가지

같은 결함이 두 모습으로 나뉘어 있습니다.

NVD / 메인테이너 시각Zellic/V12 PoC 시각
위치rxgk_verify_responserxgk_decrypt_skbrxgk_decrypt_skb
결함 종류길이 검증 반전 (CWE-130)COW 가드 누락
결과skb_to_sgvec()에서 BUG_ON() → 커널 패닉페이지 캐시 임의 위치 쓰기 → root
CVSS7.5 High (DoS)평가 없음 (LPE)
분류DoSLPE (Dirty 시리즈)

메인테이너는 V12의 DirtyDecrypt 보고에 "동일 서브시스템(net/rxrpc)의 같은 모듈군이라 중복 보고"라고 회신했고, DirtyDecrypt에는 별도 CVE가 붙지 않았습니다. 패치 자체는 메인라인에 조용히 머지됐습니다. 운영자 입장에서 중요한 것은 CVE 한 줄로 묶였더라도 영향이 DoS에 그치지는 않는다는 점입니다. NVD 등급만 보고 우선순위를 매기면 위험을 놓칩니다.

Dirty 시리즈 계보

페이지 캐시의 공유 페이지에 비특권 사용자가 결정론적 쓰기를 하는 결함은 이번이 처음이 아닙니다. 한 달도 안 된 Copy Fail (CVE-2026-31431)과도 같은 자리에 섭니다.

Dirty COW (2016)Dirty Pipe (2022)Copy Fail (2026-04)DirtyDecrypt (2026-05)
클래스race conditionflag 초기화 누락직선형 논리 버그COW 가드 누락
성공률확률적거의 결정론적결정론적결정론적
트리거 표면madvise + writeable 매핑splice + vmsplicesplice + AF_ALGrxrpc 소켓
영향 코드 경로mm/memory.cfs/splice.ccrypto/algif_aead.cnet/rxrpc/rxgk_*.c
익스플로이트 전제광범위광범위광범위RxGK 사용

DirtyDecrypt가 다른 셋과 가장 다른 지점은 트리거 표면이 좁다는 것입니다. RxRPC를 쓰는 환경은 사실상 Andrew File System(AFS) 클라이언트 또는 그 GSS 보안 계층 RxGK를 쓰는 환경에 한정됩니다.

영향 범위와 패치

제품영향 받는 버전해결 버전
Linux kernel6.19 이상 6.19.13 미만6.19.13 이상
Linux kernel6.16.1 이상 6.18.23 미만6.18.23 이상
Linux kernel7.0-rc1 ~ 7.0-rc7rc8 이후 메인라인

배포판별 노출 정도는 RxGK가 기본 활성화돼 있는지에 따라 갈립니다.

배포판RxGK 기본 상태노출
Fedora / Arch Linux / openSUSE Tumbleweed활성화즉시 영향
Debian Stable / RHEL / Ubuntu LTS비활성화일반적으로 안전

NVD 데이터는 CVE-2026-31635에, 메인라인 패치 커밋은 다음에 있습니다.

패치의 핵심은 공유 fragment를 가진 skb를 in-place로 복호화하기 전에 사본을 먼저 만든다는 한 줄입니다. 빠져 있던 COW 가드가 그 자리에 들어갑니다.

RxGK 복호화 경로의 메커니즘

RxRPC는 Andrew File System(AFS)의 네트워크 전송 프로토콜입니다. RxGK는 그 위에 GSS-API 기반 보안 계층을 얹습니다. 들어오는 데이터 패킷이 RESPONSE 인증자를 들고 오면, rxgk_verify_response()가 그 길이를 검증하고 rxgk_decrypt_skb()가 복호화를 수행합니다.

문제의 자리는 두 군데입니다.

  1. rxgk_verify_response. 길이 검증 비교 연산이 반전돼 있습니다. 비정상적으로 큰 인증자 길이가 통과해서 skb_to_sgvec()에 도달하고, 거기에서 BUG_ON()이 트리거됩니다. 이것이 NVD에 등록된 DoS 경로입니다.

  2. rxgk_decrypt_skb. 들어온 socket buffer(skb)가 페이지 캐시 페이지를 fragment로 참조하고 있을 때, 그 페이지에 in-place로 복호화 결과를 씁니다. 공유 페이지에 쓰기 전에 사본을 만드는 COW 가드가 빠져 있습니다. 이것이 V12/Zellic의 LPE 경로입니다.

후자가 왜 root로 이어지는지 살펴보겠습니다. 페이지 캐시 페이지는 커널에서 누가 그 페이지를 매핑하고 있는지 구별하지 않습니다. /etc/shadow, /etc/sudoers, /usr/bin/sudo 같은 SUID 바이너리도 동일한 페이지 캐시에 들어와 있습니다. 비특권 프로세스가 그 페이지를 read-only로 매핑해 두고, RxGK 디코드 경로를 통해 같은 페이지에 임의 바이트를 쓰면, 디스크는 그대로지만 메모리상의 권한 파일이 바뀝니다. 이후 execve() 또는 인증 검사 시점에 변조된 페이지가 그대로 사용됩니다.

이 흐름은 결정론적입니다. race condition도, ASLR 우회도 필요 없습니다. PoC는 표준 라이브러리만으로 동작합니다.

임시로 막으려면 모듈 차단

패치를 즉시 못 올리는 환경에서는 영향 모듈을 끄는 방법이 있습니다. RxGK가 의존하는 모듈 4종을 블랙리스트로 묶습니다.

# 사용 여부 확인
lsmod | grep -E '^(rxrpc|af_rxrpc|rxgk|kafs)\s'

# 사용 중이고 외부에서 필요 없다면 블랙리스트
cat <<'EOF' | sudo tee /etc/modprobe.d/rxgk-blacklist.conf
blacklist rxgk
blacklist rxrpc
blacklist af_rxrpc
blacklist kafs
EOF

# 적용 (즉시 unload)
sudo rmmod kafs rxgk rxrpc af_rxrpc 2>/dev/null || true

built-in으로 컴파일된 커널이면 unload가 불가능합니다. 두 가지 선택지가 남습니다.

  • 영향 받지 않는 커널(6.19.13 / 6.18.23 이상)으로 교체 후 재부팅
  • RxGK/RxRPC를 module 또는 disable 옵션으로 다시 컴파일

Fedora, Arch, openSUSE Tumbleweed는 보통 모듈이지만, 자체 빌드 커널은 확인이 필요합니다.

# RxGK 관련 옵션이 모듈인지 built-in인지 확인
zcat /proc/config.gz 2>/dev/null | grep -E 'CONFIG_(AFS|RXRPC|RXGK)'
# 또는
grep -E 'CONFIG_(AFS|RXRPC|RXGK)' /boot/config-$(uname -r)

=m은 모듈(블랙리스트로 처리 가능), =y는 built-in(커널 교체 필요)입니다.

지금 해야 할 일

  1. 커널 버전을 확인합니다. uname -r로 보고, 6.16.1 ~ 6.18.22 또는 6.19 ~ 6.19.12, 7.0-rc1 ~ rc7이면 영향권입니다.
  2. 배포판 노출을 평가합니다. Fedora, Arch, openSUSE Tumbleweed면 RxGK가 켜져 있을 가능성이 높고, RHEL, Ubuntu LTS, Debian Stable이면 보통 비활성화지만 자체 빌드는 확인이 필요합니다.
  3. 모듈 사용 여부를 확인합니다. lsmod | grep -E 'rxrpc|rxgk|kafs'로 적재 여부를 보면 되고, AFS 클라이언트를 안 쓰면 99% 적재돼 있지 않습니다.
  4. 패치를 적용합니다. 6.19.13 / 6.18.23 / 7.0-rc8 이후로 올리고, 배포판 패키지가 따라잡혔는지는 dnf updateinfo/apt changelog linux-image-* 등으로 확인합니다.
  5. 즉시 패치가 불가능하면 모듈을 블랙리스트에 넣습니다. 위의 conf 파일로 처리하되, AFS 클라이언트가 필요한 환경이면 패치를 우선시합니다.
  6. 변조 흔적을 점검합니다. 페이지 캐시 변조는 디스크에 남지 않지만, root 쉘 획득 후 흔적은 /var/log/auth.log, last, who, sudo 로그에 일부 남습니다. PoC 공개일(2026-05-18) 이후 비정상 sudo 호출 패턴을 검토합니다.

미배정 CVE라는 회색지대

DirtyDecrypt는 자체 CVE가 없습니다. CVE-2026-31635에 묶였고, 그 등록은 DoS 등급입니다. 운영자는 다음 두 가지를 함께 떠올려야 합니다.

  • NVD 등급은 영향 모델 중 하나만 반영합니다. 같은 결함이 다른 모델에서 더 무서울 수 있습니다.
  • CVE가 미배정이라고 결함이 작은 것도 아닙니다. 패치 머지 시점, PoC 공개 시점, 발견자 공개 시점이 운영자의 실제 시계입니다.

이번 자리의 시계는 4월 24일 NVD 공개 → 5월 18일 PoC 공개입니다. 한 달이 안 됐습니다. NVD 등급에 안주했다면 6.19.13 패치를 미뤘을 자리에, PoC가 같은 함수에서 root를 가져갔습니다.

정리

항목
별명DirtyDecrypt
CVECVE-2026-31635 (묶임, DoS 등급)
함수rxgk_decrypt_skb / rxgk_verify_response
결함COW 가드 누락 + 길이 검증 반전
영향DoS (NVD), root LPE (PoC)
CVSS3.1 7.5 High (NVD; DoS만)
영향 커널6.16.1 ~ 6.18.22/6.19 ~ 6.19.12/7.0-rc1 ~ rc7
패치6.19.13/6.18.23/7.0-rc8
PoC 공개2026-05-18
발견자Zellic/V12 보안팀
임시 완화rxgk, rxrpc, af_rxrpc, kafs 모듈 블랙리스트

AFS 클라이언트를 쓰지 않는다면 노출 면적은 좁아요. 쓰고 있다면, 또는 자체 빌드 커널에서 CONFIG_RXGK=y라면, 지금이 패치를 올릴 자리예요.

참고

Copy Fail 취약점

· 약 8분

732바이트짜리 파이썬 스크립트 하나로 2017년 이후 빌드된 거의 모든 주요 리눅스 배포판에서 root를 따낼 수 있습니다. 이번에 공개된 커널 논리 버그 Copy Fail 이야기예요.

표준 라이브러리만 쓰고, race condition도 없고, 배포판별 오프셋도, 컴파일된 셸코드 페이로드도 필요 없습니다. Ubuntu, RHEL, Amazon Linux, SUSE에서 같은 스크립트가 그대로 돕니다. 어제(2026-04-29) 공개됐고, 발견자는 한국 보안업체 Theori의 Taeyang Lee, 도구는 자체 AI 코드 감사기 Xint Code입니다.

Dirty COW/Dirty Pipe와 무엇이 다른가

이 자리는 익숙합니다. 2016년의 Dirty COW, 2022년의 Dirty Pipe도 모두 비특권 사용자가 페이지 캐시에 손을 대서 setuid 바이너리를 갈아치우는 LPE였습니다. 그런데 그 둘과 Copy Fail의 결정적인 차이는 race가 없다는 점입니다.

Dirty COW (2016)Dirty Pipe (2022)Copy Fail (2026)
클래스race conditionflag 초기화 누락직선형 논리 버그
성공률확률적 (수십~수천 회 시도)거의 결정론적첫 시도 결정론적
배포판별 오프셋필요할 때 있음거의 불필요불필요
컴파일된 페이로드보통 필요종종 필요불필요 (Python 표준 라이브러리만)
영향 기간~2007–2016~2020–20222017–패치 적용일

"직선형 논리 버그"라는 표현이 이 결함을 잘 짚습니다. 흐름이 분기 없이 일자로 흐르고, 각 단계가 의도대로 떨어지면 페이지 캐시의 정해진 위치에 정해진 바이트가 쓰입니다. 운영자 입장에서는 가장 무서운 형태의 LPE인데, 패치하기 전까지는 누구나 한 번에 성공하기 때문입니다.

세 가지 변경의 우연한 교차점

Copy Fail은 단일 버그가 아닙니다. 시기가 다른 세 가지 커널 변경이 한 자리에서 만나면서 만들어진 함정입니다.

시기변경원래 의도
2011authencesn 템플릿 추가IPsec ESP의 64비트 Extended Sequence Number 지원. 처음부터 호출자의 destination scatterlist를 임시 저장 공간으로 재사용
~2014AF_ALG 소켓에 AEAD 지원(algif_aead.c) + splice() 경로사용자 공간이 커널 crypto API를 소켓처럼 쓰도록 하기 위함
2017algif_aead에 in-place AEAD 최적화 (commit 72548b093ee3)메모리 절약 목적. 복호화 시 req->src = req->dst로 묶고, tag 페이지는 sg_chain()으로 참조만 연결

각각만 보면 그럴듯한 변경입니다. 문제는 셋이 만나는 지점입니다.

  1. splice()읽기 전용 파일의 페이지 캐시 페이지를 AF_ALG의 TX scatterlist에 그대로 참조로 넣습니다.
  2. 2017년 인-플레이스 최적화가 그 TX scatterlist를 그대로 쓰기 가능한 dst scatterlist로 재사용합니다.
  3. authencesndst[assoclen + cryptlen] 위치에 ESN 임시값(4바이트)을 씁니다. 그 자리가 1번에서 체인된 페이지 캐시 페이지입니다.

그 결과 비특권 사용자가 임의 파일의 페이지 캐시에 결정론적인 4바이트 쓰기를 하게 됩니다. 거의 10년 동안 조용히 익스플로잇 가능한 상태로 있었습니다.

익스플로잇 흐름

PoC는 copy.fail에 공개돼 있고 본문에는 옮기지 않습니다. 동작 원리는 다음과 같습니다.

순서를 풀면 이렇습니다.

  1. AF_ALG 소켓을 열어 authencesn(...) 알고리즘에 bind합니다.
  2. 타깃 setuid 바이너리(/usr/bin/su 등)를 열어 그 페이지 캐시 페이지를 splice()로 소켓의 TX scatterlist에 주입합니다.
  3. 쉘코드를 4바이트 단위로 잘라, 매번 위치 계산을 맞춰 sendmsg() + splice() 페어를 반복합니다.
  4. recv()를 호출하면 복호화 동작이 이뤄지며 authencesn이 ESN 임시값을 dst(즉 손에 들어온 페이지 캐시 페이지)에 4바이트씩 기록합니다.
  5. 마지막으로 execve("/usr/bin/su"). 손상된 페이지 캐시에서 로드된 setuid 바이너리가 쉘코드를 root 권한으로 실행합니다.

PoC가 이렇게 짧은 이유는 모든 단계가 표준 syscall과 표준 라이브러리만 쓰기 때문입니다. 컴파일러도, 외부 페이로드도 필요 없습니다.

왜 이게 무서운가

세 가지가 겹칩니다.

먼저 디스크를 건드리지 않습니다. 손상은 페이지 캐시에서만 일어나기 때문에 디스크 파일의 해시는 그대로입니다. AIDE/Tripwire 같은 무결성 모니터링이 잡지 못합니다. 재부팅하거나 페이지가 evict되면 캐시는 정상 복원되지만, 그 사이에 익스플로잇은 끝납니다.

다음으로 페이지 캐시는 호스트 전체가 공유합니다. 컨테이너는 같은 호스트 커널의 같은 페이지 캐시를 봅니다. 즉 비특권 컨테이너 안에서 일으킨 4바이트 쓰기가 호스트의 setuid 바이너리, 또는 옆 컨테이너의 read-only mount 파일을 손상시킬 수 있습니다. K8s 노드, 멀티테넌트 SaaS, 공유 CI 러너가 1순위 위험군입니다. namespace 격리만 쓰는 컨테이너 모델이 이 한 줄짜리 버그 앞에서 그대로 뚫립니다.

마지막으로 CVSS는 7.8(High)로 매겨졌습니다. Critical이 아닌 이유는 로컬 액세스가 필요해서일 뿐이고, 한번 로컬이 잡히면 결정론적으로 root까지 직행합니다. 실무 영향은 Critical과 다를 게 없습니다.

AI가 한 시간 만에 찾았다는 것의 의미

이 발견의 또 다른 화제는 도구입니다. 발견자는 Theori의 Taeyang Lee, 도구는 자체 개발한 AI 기반 보안 스캐너 Xint Code입니다. 단일 오퍼레이터 프롬프트로 Linux 커널의 crypto/ 서브시스템 전체를 약 한 시간 동안 감사했고, 이 버그가 가장 심각한 항목으로 보고됐습니다.

AI가 사람보다 똑똑해서 찾은 것은 아닙니다. "AF_ALG와 splice가 만나는 자리가 실은 거대한 비특권 공격 표면"이라는 가설을 사람이 정해주면, AI는 그 가설을 전체 코드 경로에 결정론적으로 적용해 보는 일을 사람과 비교가 안 되게 잘합니다. 그렇게 10년 동안 사람 손에 안 잡힌 코드 경로 하나를 끄집어냈습니다.

The Register는 Trend Micro Zero Day Initiative의 Dustin Childs를 인용해 "최근 취약점 제보 폭증의 가장 큰 변수는 AI 기반 버그 발견"이라고 보도했습니다. 이 흐름이 운영자 입장에서 의미하는 건 단순합니다. 패치 사이클이 더 짧아져야 하고, 오늘 멀쩡해 보이는 코드가 내일도 멀쩡하리라 가정하지 말아야 한다는 것입니다.

공개 타임라인

날짜이벤트
2026-03-23Linux 커널 보안팀에 보고
2026-03-25패치 제안
2026-04-01메인라인 커널 커밋 (a664bf3d603d)
2026-04-22CVE-2026-31431 할당
2026-04-29공개 공시

보고에서 메인라인 패치까지 9일이 걸렸습니다. 빠른 편입니다. 그러나 메인라인 패치와 각 배포판의 안정 커널이 사용자 손에 들어오는 시점 사이에는 늘 며칠에서 몇 주의 갭이 있고, 그 사이가 운영자에게 가장 위험한 구간입니다.

운영자 대응 가이드

즉시 완화, 정식 패치, 검증 세 단계로 나눠 봅니다.

1) 즉시 완화: algif_aead 모듈 차단 (재부팅 불필요)

LUKS, kTLS, IPsec은 AF_ALG 사용자 공간 인터페이스가 아닌 다른 경로를 쓰기 때문에 영향이 없습니다. 영향을 받는 건 OpenSSL의 afalg 엔진처럼 AF_ALG를 명시적으로 호출하는 경우뿐입니다. 즉 일반 서버에서는 모듈 차단으로 인한 부작용이 거의 없습니다.

단, 차단이 가능한지부터 확인해야 합니다. RHEL 9/10 일부 빌드는 algif_aead가 LKM이 아니라 커널 빌트인이고, 그러면 modprobe로 막을 수 없습니다.

# 모듈로 로드돼 있는지
lsmod | grep algif_aead

# 빌트인인지 (filename: (builtin) 이면 차단 불가)
modinfo algif_aead 2>/dev/null | head -3

LKM인 경우, modprobe로 차단합니다.

echo 'install algif_aead /bin/true' | sudo tee /etc/modprobe.d/disable-algif_aead.conf
sudo rmmod algif_aead 2>/dev/null || true

/bin/false 대신 /bin/true를 쓰는 이유는 다른 모듈이 의존성으로 algif_aead를 끌어올 때 modprobe가 에러를 뱉지 않도록 하기 위해서입니다. 어느 쪽이든 모듈은 올라오지 않습니다.

빌트인인 경우, modprobe 차단은 효과가 없습니다. 두 가지 우회 중 하나를 씁니다.

  • SELinux/AppArmor로 AF_ALG 사용을 제한합니다. 일반 사용자 도메인에서 socket(AF_ALG, ...) 호출 자체를 막는 정책입니다. 정책 변경이라 운영 검증이 필요합니다.
  • 정식 패치 일정을 앞당깁니다. 빌트인 환경에서는 사실상 이 옵션이 가장 현실적입니다.

2) 정식 패치 (재부팅 필요, kpatch 가능 시 무중단)

RHEL/Rocky/Alma:

sudo dnf updateinfo list cves | grep CVE-2026-31431 # RHSA 발행 확인
sudo dnf update kernel kernel-core kernel-modules
sudo reboot

Ubuntu/Debian:

sudo apt update
apt list --upgradable 2>/dev/null | grep -E '^linux-(image|generic|headers)'
sudo apt install --only-upgrade linux-image-$(uname -r | sed 's/-generic//')-generic
sudo reboot
# Ubuntu Pro의 경우
sudo pro status
canonical-livepatch status # 라이브 패치 발행 여부

라이브 패치(kpatch/canonical-livepatch)가 발행돼 있다면 재부팅 없이 적용 가능합니다. 프로덕션 노드는 우선 라이브 패치로 막은 뒤 다음 정기 점검 창에서 재부팅하는 편이 안전합니다.

3) 검증

uname -r

# RHEL 계열
rpm -q --changelog kernel-core | grep -i 31431 | head

# Debian/Ubuntu 계열
apt changelog linux-image-$(uname -r) 2>/dev/null | grep -i 31431 | head

CHANGELOG에 CVE 번호가 보이면 패치된 빌드입니다. 라이브 패치를 적용한 경우에는 kpatch list 또는 canonical-livepatch status로 적재된 패치 목록을 함께 확인합니다.

배포판 대응 현황

배포판초기 대응비고
Debian즉시 패치안정 채널에 빠르게 반영
Ubuntu즉시 패치Pro 사용자에겐 라이브 패치 동시 발행
SUSE즉시 패치SLES/openSUSE 모두
Red Hat처음 보류 → 입장 변경 후 신속 대응일부 빌드에서 algif_aead builtin이라 즉시 완화 옵션이 제한됨
Amazon Linux즉시 패치AL2023 우선
Arch / Fedora즉시 패치롤링 특성상 가장 빠름

여러 배포판이 초기에 "Moderate"로 분류했다가 PoC 공개와 함께 "High"로 재분류한 점도 이번 사례의 특징입니다. race 없는 LPE의 운영 영향이 통상 분류보다 크다는 걸 확인한 결과입니다.

정리

  • Copy Fail은 race 없는 결정론적 LPE입니다. 패치 전까지는 누구나 한 번에 root를 따냅니다.
  • 단일 버그가 아니라 2011, 2014, 2017년에 들어간 세 변경의 우연한 교차점에서 만들어졌습니다.
  • 페이지 캐시 쓰기 프리미티브라 디스크 무결성 검사로는 잡히지 않고, 컨테이너 격리만으로는 막지 못합니다.
  • 즉시 완화는 algif_aead 모듈 차단이지만 빌트인 환경에서는 무효입니다. 환경부터 확인해야 합니다.
  • 정식 패치는 재부팅이 필요합니다. kpatch나 canonical-livepatch가 가능한 환경이면 무중단 적용을 우선 검토합니다.

다음 글에서는 이 글에서 다루지 않은 두 가지를 따로 봐요. 컨테이너 탈출(user namespace 경계에서의 동작 검증), 그리고 AI 기반 보안 스캐너를 운영 파이프라인에 도입할 때의 트레이드오프예요.

참고