gnosis@kim:~#

gnosis@kim:~ # cat blog/copy-fail-cve-2026-31431.md

# Copy Fail: CVE-2026-31431

[Analysis]12 min read[ko]

#Linux#Kernel#Exploitation

Copy Fail 분석: AF_ALG, splice, Page Cache가 만든 4-byte Root Primitive

1. Overview

Copy Fail은 Linux kernel의 AF_ALG AEAD 처리 경로에서 발생한 local privilege escalation 취약점이다.

표면적으로는 authencesn 의 4-byte scratch write처럼 보이지만, 실제 본질은 더 깊다. splice() 를 통해 전달된 file-backed page cache page가 AF_ALG AEAD in-place 처리 과정에서 writable destination scatterlist에 섞였고, 그 결과 읽기 전용으로 열었던 파일의 page cache가 변조될 수 있었다.

이번 실습에서는 Ubuntu 24.04.2 LTS VM에서 Copy Fail PoC를 재현했고, 일반 사용자 권한에서 uid=0(root) shell 획득을 확인했다.

ItemDetails
CVECVE-2026-31431
NameCopy Fail
Vulnerable subsystemLinux kernel crypto: algif_aead
Key componentsAF_ALG, splice(), authencesn, scatterlist, page cache
PrimitiveControlled 4-byte write into readable file's page cache
ImpactLocal Privilege Escalation, container escape primitive
Test OSUbuntu 24.04.2 LTS
Test kernel6.11.0-19-generic
Resultuid=1000 → uid=0(root)

NVD는 이 취약점을 Linux kernel crypto: algif_aead 문제로 분류하며, 패치 방향을 Revert to operating out-of-place로 설명한다. 즉 핵심 수정은 특정 입력값을 필터링하는 것이 아니라, AF_ALG AEAD의 in-place 처리 구조를 되돌리는 것이다.

Copy Fail의 핵심은 다음 한 문장으로 요약할 수 있다.

source여야 할 file page cache가 writable destination scatterlist에 섞였고, authencesn의 4-byte scratch write가 그 page cache를 오염시켰다.


2. Test Environment

실습은 Docker가 아니라 VMware VM에서 진행했다.

이유는 단순하다. Docker container는 host kernel을 공유한다. Copy Fail은 kernel page cache와 crypto subsystem을 건드리는 취약점이기 때문에, Docker 안에서 실습하면 container 내부 kernel이 아니라 host kernel을 대상으로 공격하는 구조가 된다.

따라서 독립된 Ubuntu VM을 구성했다.

ItemValue
HypervisorVMware
Guest OSUbuntu 24.04.2 LTS
Kernel6.11.0-19-generic
Architecturex86_64
kmod31+20240202-2ubuntu7.1
NetworkNAT
Shared FolderDisabled
ExecutionSSH / TTY
Disk40GB

실습 전 algif_aead 모듈이 로드 가능한지 확인했다.

sudo modprobe algif_aead
echo $?
lsmod | grep -E 'algif_aead|af_alg'

결과는 다음과 같았다.

0
algif_aead             16384  0
af_alg                 32768  1 algif_aead

즉, 이 환경에서는 algif_aead가 로드 가능했고, AF_ALG AEAD 경로에 접근할 수 있었다.


3. Reproduction Result

PoC 실행 전 사용자는 일반 사용자였다.

uid=1000(rootk1m)

PoC 실행은 script로 터미널 로그를 저장하면서 진행했다.

script -q -f -c 'python3 -u ./copy_fail_exp.py' ~/copyfail-lab/evidence/03-repro-run.log

실행 결과 root shell을 얻었다.

# id
uid=0(root) gid=1000(rootk1m) groups=1000(rootk1m),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin)

# whoami
root

# uname -a
Linux rootk1m-VMware-Virtual-Platform 6.11.0-19-generic #19~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Mon Feb 17 11:51:52 UTC 2 x86_64 x86_64 x86_64 GNU/Linux

gid=0이 아니더라도 uid=0(root)가 확인되었으므로, local privilege escalation은 성공한 것으로 판단했다.

재부팅 후에는 다시 일반 사용자 상태로 돌아왔다.

uid=1000(rootk1m)

정리하면 다음과 같다.

Before:
uid=1000(rootk1m)

During PoC:
uid=0(root)

After reboot:
uid=1000(rootk1m)

이 흐름은 디스크 파일이 영구 변조된 것이 아니라, page cache 기반의 일시적 변조를 통해 setuid-root binary 실행 흐름이 바뀌었다는 점을 보여준다.


4. Background: Page Cache and splice()

Copy Fail을 이해하려면 먼저 page cache와 splice()를 봐야 한다.

Linux는 파일을 매번 디스크에서 직접 읽지 않는다. 파일 내용은 page cache에 올라가고, 이후 read(), mmap(), execve()는 이 cache를 재사용할 수 있다.

disk file
  └── page cache
        ├── read()
        ├── mmap()
        └── execve()

이 말은 다음 상태가 가능하다는 뜻이다.

on-disk /usr/bin/su: 정상
in-memory page cache of /usr/bin/su: 변조됨
execve("/usr/bin/su"): 변조된 page cache 사용

일반적인 파일 write는 다음 경로를 탄다.

write()
→ VFS permission check
→ page cache dirty
→ writeback
→ disk 변경

Copy Fail은 이 경로가 아니다.

splice()
→ AF_ALG scatterlist
→ authencesn scratch write
→ page cache memory 직접 변조
→ execve()가 변조된 page cache 사용

splice()는 데이터를 userspace buffer로 복사하지 않고 file descriptor 사이에서 이동시키는 zero-copy 계열 인터페이스다.

일반적인 read/write 흐름은 다음과 같다.

file
→ kernel buffer
→ userspace buffer
→ kernel buffer
→ socket

반면 splice()는 page reference를 전달하는 방식에 가깝다.

file page cache page
→ pipe buffer
→ target file descriptor

Copy Fail에서는 이 특성이 치명적으로 작동한다.

/usr/bin/su page cache page
→ splice()
→ pipe
→ AF_ALG socket
→ crypto scatterlist

즉, /usr/bin/su를 O_RDONLY로 열었더라도 해당 파일의 page cache reference가 crypto subsystem 내부로 들어갈 수 있다.


5. Background: AF_ALG, AEAD, scatterlist

AF_ALG는 userspace에서 Linux kernel crypto API를 socket 인터페이스로 사용할 수 있게 해주는 기능이다.

PoC는 개념적으로 다음 흐름을 만든다.

socket(AF_ALG, SOCK_SEQPACKET, 0)
bind("aead", "authencesn(hmac(sha256),cbc(aes))")
setsockopt(...)
accept()

여기서 선택되는 알고리즘은 다음이다.

authencesn(hmac(sha256),cbc(aes))

authencesn은 IPsec ESN 처리를 위해 사용되는 AEAD template이다. 이 알고리즘은 decrypt 과정에서 destination buffer를 임시 scratch space처럼 사용하는 특성이 있다.

Crypto subsystem은 데이터를 하나의 연속된 buffer로만 다루지 않는다. 여러 memory fragment를 scatterlist로 표현한다.

scatterlist:
[segment 0] → [segment 1] → [segment 2] → ...

scatterlist 자체는 성능상 자연스러운 구조다. 큰 데이터를 매번 하나의 연속 buffer로 복사하지 않고, 여러 page와 fragment를 하나의 operation으로 다룰 수 있기 때문이다.

문제는 각 segment의 출처와 ownership이 흐려질 때 발생한다.

Copy Fail에서 깨진 invariant는 다음이다.

file-backed page cache page must not be reachable through writable destination scatterlist

6. Vulnerability Analysis

Copy Fail의 취약 구조는 다음 순서로 만들어진다.

1. 사용자가 /usr/bin/su를 O_RDONLY로 연다.
2. splice()가 /usr/bin/su의 page cache page reference를 pipe로 전달한다.
3. 다시 splice()를 통해 AF_ALG socket으로 page reference가 들어간다.
4. AF_ALG AEAD in-place path가 source와 destination scatterlist를 섞는다.
5. page cache-backed tag page가 writable dst scatterlist 뒤쪽에 chain된다.
6. authencesn decrypt path가 dst[assoclen + cryptlen]에 4바이트 scratch write를 수행한다.
7. scatterwalk가 dst를 따라가다 chained page cache page에 write한다.

취약한 데이터 흐름을 단순화하면 다음과 같다.

[ /usr/bin/su ] opened O_RDONLY
        │
        ▼
+-------------------------------+
| su page cache page            |
| file-backed kernel page       |
+-------------------------------+
        │
        │ splice()
        ▼
+-------------------------------+
| pipe buffer                   |
| references page cache page    |
+-------------------------------+
        │
        │ splice() into AF_ALG socket
        ▼
+-------------------------------+
| TX scatterlist                |
| AAD || ciphertext || tag      |
| tag references page cache     |
+-------------------------------+
        │
        │ AF_ALG AEAD in-place path
        │ AAD/ciphertext copied to RX buffer
        │ tag pages chained by reference
        ▼
+------------------------------------------------+
| RX / dst scatterlist                           |
| [user recv buffer: AAD || CT] -> [tag pages]   |
|                                      ▲         |
|                                      │         |
|                         file page cache page   |
+------------------------------------------------+
        │
        │ authencesn decrypt
        │ scatterwalk_map_and_copy(..., dst,
        │ assoclen + cryptlen, 4, write=1)
        ▼
+-------------------------------+
| 4-byte write into page cache  |
+-------------------------------+

핵심은 /usr/bin/su의 page cache page가 처음에는 source였다는 점이다. 그러나 AF_ALG AEAD in-place path에서 이 page가 destination scatterlist 뒤쪽에 chain되면서 writable destination처럼 취급된다.

이것이 root cause다.

source page cache
→ destination scatterlist에 chain
→ authencesn scratch write
→ page cache corruption

GPT 생성 이미지

GPT 생성 이미지


7. The Trigger: authencesn Scratch Write

AEAD decrypt의 일반적인 입력은 다음과 같다.

AAD || ciphertext || authentication tag

출력은 보통 다음이다.

AAD || plaintext

authentication tag는 검증용 입력이지 정상적인 출력 영역이 아니다.

하지만 authencesn은 ESN 처리를 위해 destination 영역 일부를 scratch space처럼 사용한다. 공개 분석에 따르면 문제의 write는 dst[assoclen + cryptlen] 위치에 발생하는 4-byte scratch write다. 이 위치가 AF_ALG in-place path에서 chained page cache page를 가리키면, 결과적으로 file page cache가 오염된다.

단순화하면 다음과 같다.

authencesn decrypt path
→ dst[assoclen + cryptlen]에 4-byte scratch write
→ dst scatterlist 뒤쪽 chained page cache page에 도달
→ readable file의 page cache에 4-byte write

흥미로운 점은 AEAD 인증이 실패해도 write가 남는다는 것이다.

1. authencesn decrypt path 진입
2. ESN 처리를 위한 scratch write 수행
3. HMAC 계산 및 검증
4. 검증 실패
5. recvmsg()는 error 반환
6. 하지만 page cache에 쓴 4바이트는 이미 남아 있음

여기서 중요한 명제는 다음이다.

operation failed ≠ no side effect

보안적으로 중요한 질문은 “최종적으로 성공했는가?”가 아니다.

더 중요한 질문은 이것이다.

실패하기 전에 어떤 side effect가 발생했는가?

8. Exploit Analysis

PoC는 공개된 Python script를 사용했다. 코드는 매우 짧고 압축되어 있지만, 커널 내부에서 유발하는 동작은 다음 단계로 나눌 수 있다.

8.1 AF_ALG socket 생성

개념적으로는 다음과 같다.

a = socket.socket(AF_ALG, SOCK_SEQPACKET, 0)
a.bind(("aead", "authencesn(hmac(sha256),cbc(aes))"))

커널 내부 의미는 다음이다.

sys_socket()
→ AF_ALG socket 생성
→ alg_bind()
→ crypto subsystem에서 authencesn AEAD template 선택

이 단계는 userspace에서 kernel crypto API에 접근 가능한 attack surface를 연다.

8.2 AEAD request channel 생성

PoC는 setsockopt()와 accept()를 통해 실제 crypto operation을 수행할 request socket을 만든다.

setsockopt()
→ key, IV, assoclen 등 operation parameter 설정

accept()
→ crypto request를 수행할 child socket 생성

이 단계에서 아직 page cache corruption은 발생하지 않는다. 취약 조건을 준비하는 단계다.

8.3 Target file open

PoC는 /usr/bin/su를 읽기 전용으로 연다.

open("/usr/bin/su", O_RDONLY)

VFS 관점에서는 합법적인 동작이다.

can read /usr/bin/su
cannot write /usr/bin/su

Copy Fail은 이 차이를 이용한다. 파일을 쓸 수는 없지만, 파일의 page cache reference를 crypto path로 전달할 수 있다.

8.4 splice로 page cache 전달

PoC는 splice()를 이용해 target file의 page cache page를 AF_ALG 경로로 보낸다.

file fd
→ pipe write end
→ pipe read end
→ AF_ALG socket fd

커널 내부 의미는 다음이다.

/usr/bin/su page cache page
→ pipe buffer
→ AF_ALG TX scatterlist

여기서 중요한 점은 데이터가 별도 anonymous buffer로 복사되는 것이 아니라, file-backed page cache page reference가 scatterlist로 들어간다는 점이다.

8.5 decrypt path trigger

PoC는 sendmsg()로 AEAD request data와 control message를 구성하고, recv() 또는 recvmsg() 계열 호출로 decrypt path를 트리거한다.

sendmsg()
→ AAD, IV, assoclen 등 request 구성

recv()
→ crypto_aead_decrypt()
→ authencesn decrypt path 진입

이때 authencesn은 destination scatterlist에 scratch write를 수행한다.

취약한 경우 이 write가 다음 위치로 향한다.

dst[assoclen + cryptlen]

그리고 해당 위치가 chained tag page, 즉 /usr/bin/su의 page cache page를 가리키면 controlled 4-byte page cache write가 된다.

8.6 Page cache patching and setuid execution

PoC는 4-byte write primitive를 반복해 /usr/bin/su page cache 일부를 패치한다.

이후 /usr/bin/su를 실행한다.

execve("/usr/bin/su", ...)

/usr/bin/su는 setuid-root binary다.

owner: root
setuid bit: set
normal user can execute: yes
effective privilege during execution: root

변조된 page cache 기반 실행 흐름이 root 권한으로 실행되면 root shell을 얻을 수 있다.

실습 로그에서는 다음이 확인되었다.

uid=0(root)
whoami
root

9. Why Root?

Copy Fail primitive는 본질적으로 다음이다.

unprivileged user
→ readable file의 page cache에 controlled 4-byte write

이 primitive만으로 항상 root가 되는 것은 아니다. root로 이어지려면 target file 선택이 중요하다.

PoC는 /usr/bin/su를 대상으로 삼는다.

/usr/bin/su
→ root-owned
→ setuid bit set
→ normal user executable
→ executes with elevated privilege

공격 흐름은 다음과 같다.

1. /usr/bin/su를 읽기 전용으로 연다.
2. splice()로 page cache reference를 AF_ALG TX path에 전달한다.
3. in-place AEAD path에서 page cache page가 dst scatterlist에 chain된다.
4. authencesn scratch write로 page cache 일부를 변조한다.
5. 변조된 page cache를 기반으로 /usr/bin/su를 실행한다.
6. setuid-root 특성 때문에 변조된 실행 흐름이 root 권한으로 실행된다.

이 점에서 Copy Fail은 “파일 권한을 우회해서 디스크를 수정하는 취약점”이 아니다.

더 정확히는 다음이다.

readable file
→ page cache corruption
→ setuid execution path corruption
→ uid=0

10. Patch Analysis

패치의 핵심은 AF_ALG AEAD decryption path를 다시 out-of-place로 되돌리는 것이다.

취약한 구조는 다음과 같다.

in-place:
req->src == req->dst
dst scatterlist 뒤쪽에 page cache page가 chain됨

이 구조에서는 source page cache page가 destination path에서도 도달 가능해진다.

패치 이후 구조는 다음에 가깝다.

out-of-place:
req->src = TX scatterlist
req->dst = RX scatterlist

즉 splice()로 들어온 page cache page는 source 쪽에만 남고, writable destination에는 userspace receive buffer만 위치하게 된다.

패치가 복구한 invariant는 다음이다.

file page cache page must not be writable through dst scatterlist

패치 전후를 단순화하면 다음과 같다.

Before:
TX SGL: [AAD] [ciphertext] [tag page cache]
RX SGL: [RX buffer] -> [tag page cache]
authencesn scratch write reaches page cache

After:
TX SGL: [AAD] [ciphertext] [tag page cache]
RX SGL: [RX buffer only]
authencesn scratch write stays in RX buffer

따라서 fix의 본질은 특정 payload를 막는 것이 아니다. source와 destination ownership을 다시 분리하는 것이다.

GPT 생성 이미지

GPT 생성 이미지


11. Mitigation Validation Plan

운영 환경에서는 kernel update가 정석이다. 다만 실험적으로는 다음 세 가지를 비교할 수 있다.

11.1 Baseline

현재 성공 환경이다.

Ubuntu 24.04.2 LTS
kernel 6.11.0-19-generic
kmod 31+20240202-2ubuntu7.1
algif_aead loadable
PoC result: uid=0(root)

11.2 algif_aead 차단

algif_aead 모듈 자체를 차단하면 attack surface가 사라진다.

echo "install algif_aead /bin/false" | sudo tee /etc/modprobe.d/disable-algif-aead.conf
sudo modprobe -r algif_aead 2>/dev/null || true
sudo reboot

재부팅 후 확인한다.

sudo modprobe algif_aead
echo $?
lsmod | grep -E 'algif_aead|af_alg' || true

기대 결과는 다음이다.

modprobe algif_aead fails
algif_aead not loaded
PoC fails before root shell

11.3 patched kernel or kmod mitigation

clone VM에서만 업데이트를 적용한다.

sudo apt update
sudo apt install --only-upgrade kmod
dpkg -l kmod

sudo modprobe -r algif_aead 2>/dev/null || true
sudo modprobe algif_aead
echo $?
lsmod | grep -E 'algif_aead|af_alg' || true

실패 지점은 분리해서 기록해야 한다.

Failure PointMeaning
modprobe algif_aead 실패module loading 차단
bind() 실패AF_ALG AEAD template 접근 실패
sendmsg/recv 실패crypto operation은 도달했으나 primitive 실패
/usr/bin/su 실행 후 uid=1000page cache corruption 또는 payload execution 실패

중요한 점은 성공한 baseline VM을 바로 업데이트하지 않는 것이다. 반드시 clone이나 snapshot 복원본에서 mitigation 비교를 수행해야 한다.


12. CS Takeaways

Copy Fail은 단순 memory corruption이라기보다 kernel subsystem boundary에서 발생한 ownership bug다.

핵심은 다음 네 가지다.

1. zero-copy는 data copy를 줄이지만 ownership 추적을 어렵게 만든다.
2. read-only file descriptor는 read-only page를 보장하지 않는다.
3. scatterlist는 memory provenance를 강제하지 않는다.
4. failed crypto operation도 rollback되지 않은 side effect를 남길 수 있다.

이 취약점을 한 문장으로 정리하면 다음이다.

splice()로 전달된 file-backed page cache source가
AF_ALG AEAD in-place path에서 writable dst scatterlist에 섞였고,
authencesn의 4-byte scratch write가 그 page cache를 변조했다.

더 짧게 쓰면 다음이다.

source여야 할 page cache가 destination이 되었고,
그 4바이트가 setuid binary의 실행 흐름을 바꿨다.

13. Conclusion

이번 실습에서는 Ubuntu 24.04.2 LTS, kernel 6.11.0-19-generic, kmod 31+20240202-2ubuntu7.1 환경에서 Copy Fail PoC를 재현했다.

결과는 다음과 같다.

uid=1000(rootk1m)
→ PoC 실행
→ uid=0(root)
→ reboot
→ uid=1000(rootk1m)

Copy Fail의 핵심은 “4바이트를 쓸 수 있다”가 아니다.

정말 중요한 것은 그 4바이트가 어디에 쓰였는가다.

file-backed page cache
+ setuid-root binary
+ execve()
= privilege escalation

성능 최적화로 copy를 줄일수록, memory provenance와 writable ownership은 더 엄격하게 추적되어야 한다.

Copy Fail은 이 원칙이 kernel subsystem 경계에서 깨졌을 때, 작은 scratch write 하나가 root shell로 이어질 수 있음을 보여준다.


References