여담
Copy Fail을 볼 때 제일 헷갈리는 부분은 crypto 취약점인데 결과가 왜 setuid binary의 page cache corruption으로 이어지냐는 점입니다.
일단 Copy Fail은 단일 컴포넌트 하나만 보면 잘 이해가 안 됩니다. AF_ALG, AEAD, authencesn, splice, pipe_buffer, scatterlist, page cache가 한 경로에서 만나면서 생기는 문제이기 때문입니다.
공식 CVE 설명도 핵심은 짧게 말합니다. algif_aead의 in-place 동작을 되돌리고 out-of-place로 다시 처리했다는 내용입니다. 즉, 이 취약점을 이해하려면 "왜 in-place가 문제였는가", "왜 source와 destination의 mapping이 달랐는가", "왜 그 결과가 page cache write로 이어졌는가"를 보면 됩니다.
이 글은 Copy Fail 본문을 보기 전에 필요한 background만 정리합니다.
Copy Fail을 보기 전에 알아야 하는 것들
Copy Fail의 흐름을 먼저 압축하면 다음과 같습니다.
readable file
-> splice()
-> pipe_buffer가 page cache page를 참조
-> AF_ALG AEAD socket으로 전달
-> algif_aead가 scatterlist를 구성
-> authencesn이 dst scatterlist를 scratch처럼 사용
-> page cache에 4-byte write 발생
Plain Text
복사
여기서 중요한 점은 file 자체를 쓰는 게 아니라는 점입니다. 디스크 파일에 write permission이 없어도, 커널이 이미 들고 있는 page cache page가 잘못된 destination scatterlist에 들어가면 커널 내부 경로에서 그 메모리가 수정될 수 있습니다.
전체 구성 요소
개념 | Copy Fail에서의 역할 |
AF_ALG | userspace에서 kernel crypto API를 socket 형태로 호출하는 입구 |
AEAD | AAD, ciphertext, auth tag를 하나의 crypto request로 처리하는 타입 |
authencesn | IPsec ESN용 AEAD template. dst buffer를 임시 공간처럼 다루는 특성이 있음 |
splice() | file page를 user copy 없이 pipe/FD 사이로 넘기는 경로 |
pipe_buffer | pipe 안에서 struct page *page, offset, len으로 데이터를 표현 |
page cache | 일반 file read/mmap/write가 거치는 커널의 file-backed memory |
scatterlist | crypto API가 contiguous buffer 대신 page 조각들의 목록을 다루는 방식 |
in-place | source와 destination을 같은 buffer로 보고 처리하는 최적화 |
out-of-place | source와 destination을 분리해서 처리하는 방식 |
AF_ALG
AF_ALG는 userspace에서 kernel crypto API를 사용할 수 있게 만든 socket interface입니다.
일반적인 흐름은 다음과 같습니다.
socket(AF_ALG, SOCK_SEQPACKET, 0);
bind(algfd, { .salg_type = "aead", .salg_name = "authencesn(...)" });
accept(algfd, NULL, 0);
C
복사
이후 실제 데이터는 accept()로 얻은 operation fd에 대해 sendmsg(), write(), splice(), recvmsg() 같은 syscall로 전달됩니다.
즉 AF_ALG는 단순히 crypto를 계산하는 라이브러리 호출이 아닙니다. userspace syscall이 kernel crypto subsystem 안쪽의 transform, request, scatterlist 구성까지 도달하게 만드는 interface입니다.
공식 kernel 문서는 현재도 AF_ALG와 splice()/vmsplice() 기반 zero-copy interface를 설명합니다. Copy Fail을 이해할 때는 userspace가 MSG_SPLICE_PAGES를 직접 넘긴다고 보면 안 되고, splice(pipe -> AF_ALG socket)의 kernel 구현이 page-backed iterator와 해당 flag를 만든다는 점을 봐야 합니다.
AEAD
AEAD는 Authenticated Encryption with Associated Data의 약자입니다.
여기서 데이터는 크게 세 부분으로 나뉩니다.
구성 | 의미 |
AAD | 암호화되지는 않지만 인증에는 포함되는 데이터 |
plaintext/ciphertext | 실제 암복호화 대상 |
authentication tag | 무결성 검증에 쓰이는 tag |
AF_ALG의 AEAD API에서 decryption input은 다음 순서로 들어갑니다.
AAD || ciphertext || authentication tag
Plain Text
복사
그리고 decryption output은 보통 다음과 같습니다.
plaintext
Plain Text
복사
여기서 AAD length는 ALG_SET_AEAD_ASSOCLEN으로 전달됩니다. 이 값이 중요한 이유는 crypto 구현이 AAD, ciphertext, tag의 경계를 이 길이값으로 계산하기 때문입니다.
Copy Fail에서 offset 계산이 계속 assoclen + cryptlen 형태로 나오는 이유도 이 때문입니다. kernel crypto 쪽에서는 하나의 linear buffer가 아니라 scatterlist 위에서 이 논리적 offset을 따라갑니다.
authencesn
authencesn은 일반적인 AEAD 알고리즘이라기보다는 IPsec ESN(Extended Sequence Number)을 처리하기 위한 AEAD template입니다.
여기서 중요한 점은 암호학적인 의미보다 구현상의 동작입니다. authencesn은 ESN 처리를 위해 sequence number 일부를 앞뒤로 옮기고, 이 과정에서 destination scatterlist를 임시 공간처럼 사용합니다.
문제의 핵심이 되는 형태는 다음 관점입니다.
1. dst에서 일부 값을 읽음
2. dst의 앞쪽 또는 assoclen + cryptlen 근처에 4바이트를 씀
3. hash/authentication 계산을 진행함
4. 실패하더라도 일부 scratch write는 이미 발생한 상태가 될 수 있음
Plain Text
복사
일반적인 crypto 구현이라면 output buffer 안에서만 write가 발생한다고 생각하기 쉽습니다. 그런데 authencesn은 ESN 재배치를 위해 dst[assoclen + cryptlen] 근처를 사용합니다. 이 위치가 안전한 일반 buffer라면 문제가 제한적이지만, Copy Fail에서는 이 dst가 page cache page를 향하게 되면서 문제가 커집니다.
정리하면 authencesn은 Copy Fail에서 "실제로 4-byte write를 수행하는 컴포넌트"입니다.
splice와 pipe_buffer
splice()는 두 file descriptor 사이에서 데이터를 옮길 때 user space로 복사했다가 다시 kernel space로 복사하는 과정을 피하기 위해 만들어진 syscall입니다.
중요한 제약은 둘 중 하나의 fd가 pipe여야 한다는 점입니다.
file fd -> pipe fd -> another fd
Plain Text
복사
pipe 내부의 데이터는 단순한 byte array가 아니라 pipe_buffer로 표현됩니다.
struct pipe_buffer {
struct page *page;
unsigned int offset, len;
const struct pipe_buf_operations *ops;
unsigned int flags;
unsigned long private;
};
C
복사
여기서 중요한 필드는 page, offset, len입니다. 즉 pipe는 "이 byte들을 새로 복사해서 들고 있다"가 아니라 "이 page의 이 offset부터 이 길이만큼이 pipe data다"라는 식으로 표현할 수 있습니다.
file에서 pipe로 splice하면 file data가 page cache page를 통해 pipe_buffer에 들어올 수 있습니다.
file read path
-> page cache page
-> pipe_buffer.page
Plain Text
복사
Copy Fail에서 splice()가 중요한 이유는 이 때문입니다. attacker가 file을 write하지 않아도, readable file의 page cache page reference를 crypto input 경로에 태울 수 있습니다.
Page Cache
Page cache는 file content를 메모리에 캐싱하는 커널 메커니즘입니다.
일반적인 file read, write, mmap은 page cache를 거칩니다. 디스크에서 매번 읽는 대신, 커널은 file-backed page를 메모리에 올려두고 재사용합니다.
process read("/usr/bin/su")
|
v
kernel page cache
|
v
disk filesystem
Plain Text
복사
여기서 중요한 점은 page cache가 단순한 성능 최적화 이상의 의미를 가진다는 것입니다. 실행 파일을 실행할 때도 커널은 파일 내용을 page cache를 통해 읽고 매핑할 수 있습니다.
따라서 page cache 안의 setuid binary page가 수정되면, 디스크 파일이 바뀌지 않았더라도 이후 실행 경로는 수정된 in-memory page를 볼 수 있습니다.
이게 Copy Fail이 local privilege escalation으로 이어지는 이유입니다.
on-disk /usr/bin/su : unchanged
page cache /usr/bin/su : corrupted
execve("/usr/bin/su") : page cache content 사용 가능
Plain Text
복사
Scatterlist
Scatterlist는 이름 그대로 흩어져 있는(scattered) 메모리 조각들의 목록(list) 입니다.
일반적인 userspace 코드에서는 데이터를 보통 하나의 연속된 버퍼로 생각합니다.
buf = [ AAD ][ ciphertext ][ tag ]
Plain Text
복사
하지만 커널 내부에서는 데이터가 항상 이렇게 연속된 가상 주소 하나에 붙어 있지 않습니다. file page, pipe buffer, network packet, userspace iovec처럼 여러 page에 나뉘어 있을 수 있습니다.
page A offset 0x100 len 0x40
page B offset 0x000 len 0x1000
page C offset 0x300 len 0x10
Plain Text
복사
이 조각들을 하나의 논리적인 데이터 스트림처럼 다루기 위해 쓰는 구조가 struct scatterlist입니다.
개념적으로는 다음과 같습니다.
struct scatterlist {
struct page *page;
unsigned int offset;
unsigned int length;
};
C
복사
실제 구조체에는 flag, chain 정보 등이 더 들어가지만, Copy Fail을 이해할 때 핵심은 이 세 가지입니다.
page : 어느 physical page / struct page를 가리키는가
offset : 그 page 안에서 몇 바이트 지점부터 시작하는가
length : 몇 바이트를 데이터로 볼 것인가
Plain Text
복사
즉 scatterlist entry 하나는 이런 의미입니다.
"이 데이터 조각은 page X의 offset Y부터 length Z만큼이다"
Plain Text
복사
여러 entry를 이어 붙이면, 커널은 이것을 하나의 긴 buffer처럼 다룰 수 있습니다.
sg[0] -> page A, offset 0x100, len 0x40
sg[1] -> page B, offset 0x000, len 0x1000
sg[2] -> page C, offset 0x300, len 0x10
logical buffer = sg[0] || sg[1] || sg[2]
Plain Text
복사
그래서 kernel crypto API도 char *src, char *dst 하나를 받는 대신 src scatterlist, dst scatterlist를 받습니다.
aead_request_set_crypt(req, src_sg, dst_sg, cryptlen, iv);
C
복사
여기서 src_sg는 input 조각들의 목록이고, dst_sg는 output 조각들의 목록입니다.
sg_set_page()
scatterlist entry에 page를 붙일 때는 보통 sg_set_page() 같은 helper를 씁니다.
sg_set_page(sg, page, len, offset);
C
복사
이 말은 데이터를 복사한다는 뜻이 아닙니다. 그냥 scatterlist entry가 특정 struct page를 가리키도록 만드는 것입니다.
Copy Fail에서 이 차이가 중요합니다.
memcpy(data) : byte가 새 buffer로 복사됨
sg_set_page(page) : page reference가 scatterlist에 들어감
Plain Text
복사
따라서 splice()로 file page cache page가 pipe에 들어오고, 두 번째 splice가 그 page를 bio_vec와 ITER_BVEC로 바꾼 뒤 af_alg_sendmsg()가 sg_set_page()로 TX scatterlist에 붙이면, crypto layer는 그 page를 input buffer 일부처럼 보게 됩니다.
file page cache page
-> pipe_buffer.page
-> splice_to_socket()
-> bio_vec.bv_page
-> kernel 내부 MSG_SPLICE_PAGES
-> af_alg_sendmsg()
-> extract_bvec_to_sg()
-> sg_set_page(tx_sg, page, len, offset)
-> TX scatterlist
Plain Text
복사
sg_chain()
scatterlist는 하나의 배열로 끝나지 않고, 다른 scatterlist를 뒤에 chain할 수도 있습니다.
개념적으로는 다음과 같습니다.
RX SGL:
sg[0] -> user output page
sg[1] -> user output page
TX TAG SGL:
sg[0] -> file page cache page
sg_chain(RX SGL, TX TAG SGL)
result:
sg[0] -> user output page
sg[1] -> user output page
sg[2] -> file page cache page
Plain Text
복사
이렇게 되면 코드 입장에서는 dst scatterlist 하나를 따라가는 것처럼 보이지만, 실제 마지막 entry는 전혀 다른 출처의 page일 수 있습니다.
Copy Fail에서 왜 중요한가
Copy Fail에서 문제는 scatterlist 자체가 아닙니다. 문제는 원래 input으로만 쓰여야 할 page cache page가 destination scatterlist 경로에 섞였다는 것입니다.
정상적인 out-of-place 구조라면 다음처럼 분리됩니다.
src_sg = TX SGL
-> AAD, ciphertext, tag
-> splice로 들어온 page cache page 포함 가능
dst_sg = RX SGL
-> recvmsg()로 userspace가 받는 output buffer
Plain Text
복사
이 경우 page cache page는 source입니다. crypto가 읽을 수는 있어도, output write 대상은 아닙니다.
그런데 in-place 처리 과정에서 RX SGL 뒤에 TX SGL 일부가 chain되면 그림이 달라집니다.
dst_sg = RX SGL -> chained TX SGL
-> page cache page
Plain Text
복사
이 상태에서 authencesn이 다음처럼 destination scatterlist에 write하면,
memcpy_to_sglist(dst, assoclen + cryptlen, tmp + 1, 4);
C
복사
dst + assoclen + cryptlen 위치를 찾기 위해 scatterlist를 따라가다가 page cache page에 도달할 수 있습니다.
정리하면 scatterlist는 이런 역할입니다.
여러 page 조각을 하나의 논리적 buffer처럼 보이게 만드는 커널 자료구조입니다. Copy Fail에서는 이 추상화 때문에 page의 원래 출처가 흐려졌고, input이어야 할 page cache page가 destination write 경로에 섞이면서 문제가 됐습니다.
In-place와 Out-of-place
crypto에서 in-place는 source와 destination이 같은 buffer인 경우입니다.
src == dst
Plain Text
복사
장점은 copy를 줄일 수 있다는 점입니다. input buffer 위에 output을 바로 덮어쓰면 별도 destination buffer가 필요 없습니다.
반대로 out-of-place는 source와 destination을 분리합니다.
src != dst
Plain Text
복사
Copy Fail의 공식 fix가 "out-of-place로 되돌린다"는 표현을 쓰는 이유가 여기 있습니다.
문제가 된 algif_aead path에서는 source와 destination이 실제로는 다른 mapping에서 온 데이터였습니다. source에는 splice()로 들어온 page cache page가 섞일 수 있었고, destination은 userspace가 recvmsg()로 받는 output buffer 쪽이어야 했습니다.
그런데 in-place 최적화가 들어가면서 source/destination 구성이 복잡해졌고, 결과적으로 page cache page가 destination처럼 취급될 수 있는 형태가 만들어졌습니다.
원래 기대:
src: spliced file page cache
dst: user output buffer
문제가 되는 관점:
dst scatterlist chain 안에 page cache page가 걸림
Plain Text
복사
이 상태에서 authencesn이 dst + assoclen + cryptlen 위치에 4바이트를 쓰면, 그 write가 user output buffer가 아니라 page cache page로 향할 수 있습니다.
다음 글에서 보는 kernel code
여기까지는 Copy Fail에 필요한 자료구조와 역할을 한 번에 잡기 위한 개념 지도다.
코드 분석은 두 경로로 나눠 보는 편이 명확하다.
AF_ALG path
socket / bind / accept
sendmsg CMSG
TX/RX scatterlist
vulnerable algif_aead request
-> Background (2)
splice path
filemap_splice_read
pipe_buffer.page
splice_to_socket
ITER_BVEC
-> Background (3)
Plain Text
복사
두 경로가 합쳐진 뒤 source TAG page가 실제로 write되는 지점과 patch 전후 차이는 본편에서 본다.
Copy Fail에서 하나로 묶이는 흐름
이제 전체 흐름을 다시 보면 다음과 같습니다.
1. attacker는 readable setuid binary를 open
2. splice()로 file page를 pipe에 올림
3. pipe_buffer는 page cache page를 참조
4. AF_ALG AEAD socket에 authencesn transform을 선택
5. algif_aead가 AEAD request의 src/dst scatterlist를 구성
6. in-place 처리 과정에서 page cache page가 dst path에 섞임
7. authencesn이 ESN 처리를 위해 dst[assoclen + cryptlen] 근처에 4바이트 write
8. 그 위치가 page cache page를 가리키면 file-backed memory가 수정됨
Plain Text
복사
여기서 recvmsg()가 성공해야만 의미가 있는 것은 아닙니다. 중요한 side effect는 crypto operation 중간의 scratch write입니다. authentication이 나중에 실패하더라도, 앞에서 발생한 write가 되돌려지지 않으면 page cache corruption primitive가 됩니다.
이 primitive는 대략 이렇게 볼 수 있습니다.
controlled 4 bytes + controlled target offset inside page cache
Plain Text
복사
그래서 Copy Fail을 단순히 "AF_ALG bug"라고만 보면 부족합니다.
정확히는 다음 조합입니다.
AF_ALG exposes kernel crypto to userspace
+ splice can feed page cache pages into that path
+ scatterlist abstracts those pages as crypto buffers
+ algif_aead in-place path mixes src/dst assumptions
+ authencesn performs a 4-byte write through dst
= page cache write primitive
Plain Text
복사
왜 권한 상승으로 이어지는가
권한 상승은 page cache write primitive가 어느 파일에 적용되느냐에 달려 있습니다.
읽을 수 있는 setuid-root binary가 target이면, attacker는 디스크 파일을 직접 수정하지 않아도 in-memory cached copy를 바꿀 수 있습니다.
attacker privilege: normal user
file permission : readable, not writable
file type : setuid-root executable
corruption target : page cache page
result : modified code path executes with euid 0
Plain Text
복사
중요한 점은 디스크 파일이 그대로일 수 있다는 점입니다. 그래서 파일 해시나 디스크 기반 integrity check만 보면 이상이 없어 보일 수 있습니다.
결국 Copy Fail은 "readable file의 page cache에 controlled 4-byte write를 만들고, 그 file이 privileged execution path에 들어가면 root code execution으로 연결된다"고 정리할 수 있습니다.
볼 때 헷갈리지 말아야 할 점
AF_ALG가 곧 취약점은 아니다
AF_ALG는 입구입니다. 취약점은 algif_aead의 in-place 처리와 authencesn의 destination write, 그리고 splice()로 들어온 page cache page가 만나는 지점에서 발생합니다.
splice가 곧 write primitive는 아니다
splice() 자체는 데이터를 옮기는 syscall입니다. 다만 pipe_buffer가 page reference를 들 수 있기 때문에, 그 page가 잘못된 writer path에 들어가면 문제가 됩니다.
page cache corruption은 disk write와 다르다
Copy Fail의 핵심은 disk file overwrite가 아닙니다. page cache 안의 file-backed memory가 바뀌는 것입니다.
auth failure가 안전을 보장하지 않는다
crypto operation이 최종적으로 실패하더라도, 내부 구현이 이미 destination에 쓴 값이 원복되지 않으면 side effect는 남을 수 있습니다.
정리
Copy Fail을 이해할 때는 다음 문장으로 잡으면 됩니다.
userspace가 AF_ALG로 kernel AEAD path를 호출하고, splice()로 readable file의 page cache page를 crypto input에 태운 뒤, algif_aead의 in-place scatterlist 구성이 authencesn의 4-byte destination write와 만나 page cache를 수정하게 된 취약점입니다.
그래서 background에서 봐야 할 핵심은 네 가지입니다.
1.
AF_ALG는 userspace가 kernel crypto API를 직접 호출하게 해준다.
2.
splice()와 pipe_buffer는 page cache page reference를 syscall 경로 사이로 넘길 수 있다.
3.
kernel crypto는 scatterlist 기반이라 page provenance가 추상화된다.
4.
authencesn의 dst scratch write가 잘못된 destination page를 만나면 page cache corruption이 된다.
이 네 개를 잡고 Copy Fail 본문을 보면, 왜 공식 fix가 단순히 "out-of-place로 되돌림"인지도 자연스럽게 보입니다. source와 destination을 분리하면 page cache page가 writable destination으로 취급되는 구성이 끊기기 때문입니다.