Home
System Hacking
🏀

Copy Fail(CVE-2026-31431)란?

Type
CVE
날짜
2026/07/13
종류
Kernel
Linux
1 more property

여담

Dirty COW를 정리하고 보니까 page cache가 다시 나오는 취약점이라 같이 정리해두면 좋을 것 같았다. Copy Fail은 이름만 보면 copy 계열 API 실수처럼 보이지만, 실제 핵심은 AF_ALG, splice, scatterlist, authencesn이 연결되면서 read-only source page가 destination write path에 들어간 것이다.
이 글은 Copy Fail의 개요와 page cache 4-byte write primitive를 하나로 합친 본편이다. exploit script 자체보다 kernel code에서 source page의 provenance가 어디서 깨지고, 실제 memcpy()가 어느 page를 수정하는지 따라간다.
이 글은 공개된 CVE와 upstream patch를 분석한다. 실제 시스템에서는 재현보다 kernel/vendor update가 우선이다. upstream stable fix, Red Hat advisory, Ubuntu advisory

읽는 순서

본편만 읽어도 전체 흐름을 따라갈 수 있지만, 구조체와 subsystem을 먼저 보고 싶다면 다음 순서가 편하다.
Background (1) 전체 개념 지도 Background (2) AF_ALG란? Background (3) AF_ALG Code by Code Background (4) splice()와 pipe Code by Code 본편 Page Cache 4-Byte Write와 LPE
Plain Text
복사

취약점 요약

Copy Fail의 primitive는 page cache write다.
일반적인 파일 write와는 다르다. disk에 바로 쓰는 게 아니라 kernel memory 안에 올라와 있는 file-backed page cache page가 바뀐다.
on disk file unchanged page cache page modified in memory read / mmap / exec may observe page cache content
Plain Text
복사
이게 왜 LPE로 이어질 수 있냐면, 실행 파일도 page cache를 거친다. 읽기 가능한 setuid-root binary의 page cache가 attacker-controlled byte로 바뀌면, disk file은 그대로인데 실행 시점의 code/data view가 달라질 수 있다.
이 글에서는 실제 payload byte나 exploit 안정화보다, kernel이 어떻게 source page를 destination처럼 쓰게 되는지에 집중한다.

영향 범위

Vulnerability : CVE-2026-31431
Vulnerable component : Linux Kernel crypto/algif_aead.c
Write consumer : crypto/authencesn.c
Primitive : readable file의 page cache에 controlled 4-byte write
Impact : Local Privilege Escalation, 환경에 따라 container escape
CVSS : 7.8 HIGH
Public disclosure : 2026-04-29

코드 읽는 지도

공유용 글에서 단순히 파일 경로만 적으면 독자가 “이 파일이 뭘 하는지”를 알기 어렵다. 그래서 먼저 파일 역할부터 잡고 간다.
파일
역할
이 글에서 보는 함수
mm/filemap.c
일반 파일의 page cache folio를 읽어 pipe buffer에 연결하는 구현이다. file byte를 새 buffer로 복사하지 않고 page reference를 pipe에 넘기는 지점을 보여준다.
filemap_splice_read(), splice_folio_into_pipe()
fs/splice.c
splice() syscall의 file-to-pipe와 pipe-to-socket 경로를 연결한다. pipe의 page를 bio_vec로 바꾸고 kernel 내부에서 MSG_SPLICE_PAGES를 설정한다.
do_splice(), splice_file_to_pipe(), do_splice_from(), splice_to_socket()
lib/scatterlist.c
ITER_BVEC iterator가 가진 page fragment를 Crypto API scatterlist entry로 변환한다.
extract_iter_to_sg(), extract_bvec_to_sg()
crypto/af_alg.c
AF_ALG socket의 공통 구현이다. socket(), bind(), setsockopt(), accept(), sendmsg() 공통 경로와 TX/RX SGL helper를 담당한다.
alg_create(), alg_bind(), af_alg_accept(), af_alg_sendmsg(), af_alg_pull_tsgl(), af_alg_get_rsgl()
crypto/algif_aead.c
AEAD 알고리즘을 AF_ALG userspace socket으로 노출하는 wrapper다. Copy Fail의 핵심 패치가 들어간 파일이다.
aead_sendmsg(), _aead_recvmsg(), aead_accept_parent(), algif_type_aead
crypto/authencesn.c
authencesn(hmac(sha256),cbc(aes)) AEAD template 구현이다. 패치 대상은 아니지만, 취약한 destination SGL을 실제로 쓰는 consumer다.
crypto_authenc_esn_decrypt(), crypto_authenc_esn_decrypt_tail()
include/linux/scatterlist.h
Linux scatterlist 자료구조 정의다. SGL이 data buffer가 아니라 page + offset + length descriptor라는 점을 보여준다.
struct scatterlist, sg_set_page(), sg_page(), sg_chain(), sg_next()
crypto/scatterwalk.c, include/crypto/scatterwalk.h
scatterlist를 따라가며 실제 copy/read/write를 수행하는 helper다.
memcpy_to_sglist(), memcpy_sglist(), scatterwalk_map_and_copy()
include/crypto/if_alg.h
AF_ALG 내부 구조체와 helper prototype이 정의된 header다.
struct alg_sock, struct af_alg_ctx, struct af_alg_async_req, af_alg_pull_tsgl() prototype
crypto/algif_skcipher.c
skcipher용 AF_ALG wrapper다. Copy Fail root cause는 아니고, af_alg_pull_tsgl() signature 변경 때문에 같이 수정된 caller다.
_skcipher_recvmsg()
패치 diff가 직접 걸린 파일은 crypto/af_alg.c, crypto/algif_aead.c, crypto/algif_skcipher.c, include/crypto/if_alg.h다. 반대로 mm/filemap.c, fs/splice.c, lib/scatterlist.c, authencesn.c, scatterwalk.c, scatterlist.h는 patch target이 아니다. 이 파일들은 “target file의 page가 어떤 descriptor를 거쳐 AF_ALG로 들어오고, 취약한 SGL이 실제 page memory write로 어떻게 이어지는지”를 보여주는 경로 코드다.

본편에서 시작할 위치

AF_ALG의 parent/child socket, CMSG parsing, TX/RX SGL은 Background (3)에서 봤고, target file page가 pipe를 거쳐 AF_ALG TX SGL에 들어오는 과정은 Background (4)에서 봤다.
본편은 두 경로가 합쳐진 다음 시점부터 시작한다.
AF_ALG child socket ctx->aead_assoclen = 8 ctx->enc = decrypt ctx->tsgl_list logical stream [ AAD 8 bytes ][ target file bytes 0 .. t+3 ] physical backing AAD SG -> AF_ALG private page FILE SG -> target file page-cache page P
Plain Text
복사
실제 exploit syscall은 다음 형태다.
sendmsg(opfd, AAD="AAAA" || new_bytes, MSG_MORE) pipe(pipefd) splice(target_fd, off=0, pipefd[1], len=t+4) splice(pipefd[0], opfd, len=t+4) recv(opfd, len=8+t)
Plain Text
복사
두 번째 splice 내부에서 kernel이 MSG_SPLICE_PAGESITER_BVEC를 만들고, 최종적으로 다음 pointer identity가 성립한다.
target page P == pipe_buffer.page == bio_vec.bv_page == sg_page(FILE_SG)
Plain Text
복사
이 상태에서 recvmsg()가 RX output SGL을 만들고 vulnerable algif_aead request를 구성한다.

Step 1. recvmsg에서 RX SGL을 만든다

위치: crypto/af_alg.c:1231-1300
recvmsg()는 output buffer를 제공한다. AF_ALG는 이 output iovec도 SGL로 만든다.
af_alg_get_rsgl(sk, msg, flags, areq, outlen, &usedpages) rsgl = &areq->first_rsgl or new rsgl sg_init_table(rsgl->sgl.sgt.sgl, ALG_MAX_PAGES) extract_iter_to_sg(&msg->msg_iter, seglen, &rsgl->sgl.sgt, ...) sg_mark_end(last) if previous rsgl exists: af_alg_link_sg(previous, current)
Plain Text
복사
결과적으로 request에는 두 종류의 memory view가 존재한다.
TX side ctx->tsgl_list source data from sendmsg() RX side areq->first_rsgl destination buffer from recvmsg()
Plain Text
복사
정상적인 out-of-place crypto라면 다음처럼 되어야 한다.
req->src = TX SGL req->dst = RX SGL
Plain Text
복사
하지만 vulnerable AEAD decrypt는 여기서 in-place 최적화를 위해 다른 구조를 만든다.

Step 2. vulnerable algif_aead decrypt

vulnerable 위치: crypto/algif_aead.c:66-290
_aead_recvmsg()는 먼저 길이를 계산한다.
used = ctx->used as = crypto_aead_authsize(tfm) if decrypt: outlen = used - as used -= ctx->aead_assoclen processed = used + ctx->aead_assoclen
Plain Text
복사
AEAD decrypt input layout은 다음이다.
TX input = AAD || CT || TAG ctx->used = len(AAD) + len(CT) + len(TAG) outlen = len(AAD) + len(CT) used = len(CT) + len(TAG) processed = len(AAD) + len(CT) + len(TAG)
Plain Text
복사
vulnerable decrypt는 먼저 TX SGL에서 첫 valid SG를 tsgl_src로 잡는다.
위치: crypto/algif_aead.c:157-173
list_for_each_entry_safe(tsgl, tmp, &ctx->tsgl_list, list): for each sg: if sg has length and sg_page(sg): tsgl_src = sg break
Plain Text
복사
그 다음 decrypt branch가 문제다.
위치: crypto/algif_aead.c:205-248
// 1. RX SGL을 source/destination base로 사용한다. rsgl_src = areq->first_rsgl.sgl.sgt.sgl // 2. AAD || CT를 TX에서 RX로 복사한다. memcpy_sglist(RX_SGL, tsgl_src, outlen) // 3. TAG 부분만 담을 areq->tsgl을 만든다. areq->tsgl_entries = af_alg_count_tsgl(sk, processed, processed - as) areq->tsgl = sock_kmalloc(...) sg_init_table(areq->tsgl, areq->tsgl_entries) // 4. TX SGL을 소비하면서 TAG 부분만 areq->tsgl에 재지정한다. af_alg_pull_tsgl(sk, processed, areq->tsgl, processed - as) // 5. RX SGL 뒤에 TAG SGL을 chain한다. sg_unmark_end(last_rx_sg) sg_chain(RX_SGL, ..., areq->tsgl) // 6. crypto request를 in-place처럼 만든다. aead_request_set_crypt(src = rsgl_src, dst = RX_SGL, cryptlen = used, iv = ctx->iv)
Plain Text
복사
이 코드는 “AAD || CT는 RX에 복사하고, TAG는 원래 TX source page를 그대로 chain해서 in-place처럼 보이게 하자”는 구조다.
문제는 pipe-to-AF_ALG splice가 kernel 내부 MSG_SPLICE_PAGES 경로로 넣은 FILE SG가 target page-cache page를 가리킨다는 점이다.

Step 2-1. vulnerable 메모리 상태

target offset을 t라고 하자.
exploit이 만든 TX logical stream은 AAD || CT || TAG지만, 실제 backing page는 둘로 나뉜다.
ctx->tsgl_list TX[0] AAD_SG -> AF_ALG private page [ "AAAA" ][ new_bytes 4 ] TX[1] FILE_SG -> target file page-cache page P [ file 0 .. t-1 ][ file t .. t+3 ] ciphertext 4-byte TAG
Plain Text
복사
즉 AAD까지 page cache에 있는 것이 아니다. 공격자가 쓸 값은 일반 sendmsg(MSG_MORE)로 AF_ALG private page에 넣고, overwrite 대상이 되는 file page만 splice로 전달한다.
recvmsg()가 output buffer를 주면 RX SGL이 만들어진다.
areq->first_rsgl RX[0] -> user output page length = 8 + t
Plain Text
복사
vulnerable decrypt는 memcpy_sglist(..., outlen)으로 AAD 8 byte와 file prefix 0..t-1을 RX에 복사한다.
RX logical content [ AAD 8 ][ file 0 .. t-1 ] total length = 8 + t target page P [ file 0 .. t-1 ][ file t .. t+3 ] TAG source
Plain Text
복사
그 다음 af_alg_pull_tsgl(sk, processed, areq->tsgl, processed - as)가 TX logical stream의 마지막 4 byte만 새 SGL로 재지정한다.
areq->tsgl TAG_SG page = target page-cache page P offset = t length = 4
Plain Text
복사
그리고 RX 뒤에 TAG_SG를 chain한다.
req->src base = RX[0] req->dst base = RX[0] RX[0] -> user output page [ AAD ][ file prefix 0..t-1 ] | +-- chain --> TAG_SG -> page P [ file t..t+3 ]
Plain Text
복사
이 순간 read-only source였던 target page P의 마지막 4-byte fragment가 req->dst walk 안으로 들어온다.

Step 3. af_alg_pull_tsgl의 dst_offset

vulnerable 위치: crypto/af_alg.c:648-765
취약 버전의 af_alg_pull_tsgl() signature는 다음이었다.
void af_alg_pull_tsgl(struct sock *sk, size_t used, struct scatterlist *dst, size_t dst_offset)
C
복사
핵심은 dst_offset이다.
if dst_offset >= plen: dst_offset -= plen else: get_page(page) sg_set_page(dst + j, page, plen - dst_offset, sg[i].offset + dst_offset)
Plain Text
복사
즉 TX SGL 앞부분은 버리고, 특정 offset 이후만 dst SGL에 다시 꽂을 수 있다.
AEAD decrypt는 processed - as를 offset으로 넘긴다.
processed - as = TAG 시작 offset
Plain Text
복사
그래서 AAD/CT는 RX로 복사하고, TAG는 source page를 그대로 재사용한다.
이 설계 자체가 Copy Fail의 직접적인 원인이다. source page를 destination SGL chain에 넣을 수 있는 API와 caller가 같이 있었다.

Step 4. authencesn이 req->dst에 쓴다

이제 algif_aead는 AEAD request를 만들었고, 실제 algorithm은 authencesn이다.
위치: crypto/authencesn.c:247-294
crypto_authenc_esn_decrypt(req) authsize = crypto_aead_authsize(authenc_esn) assoclen = req->assoclen cryptlen = req->cryptlen dst = req->dst cryptlen -= authsize if (req->src != dst) memcpy_sglist(dst, req->src, assoclen + cryptlen) scatterwalk_map_and_copy(ihash, req->src, assoclen + cryptlen, authsize, 0) scatterwalk_map_and_copy(tmp, dst, 0, 8, 0) scatterwalk_map_and_copy(tmp, dst, 4, 4, 1) scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1)
Plain Text
복사
scatterwalk_map_and_copy()의 마지막 인자 out1이면 write다.
위치: include/crypto/scatterwalk.h:241-250
if out: memcpy_to_sglist(sg, start, buf, nbytes) else: memcpy_from_sglist(buf, sg, start, nbytes)
Plain Text
복사
vulnerable AEAD decrypt에서 req->dst는 RX SGL base다. 그런데 RX 뒤에는 TAG source SGL이 chain되어 있다.
req->dst walk RX[0] [ AAD ][ CT ] -> TAG_SG [ TAG in page cache ]
Plain Text
복사
따라서 다음 write가 assoclen + cryptlen offset을 따라가면 TAG_SG에 도달할 수 있다.
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1)
Plain Text
복사
결과:
page cache page의 TAG 위치 4 byte가 overwrite된다.
Plain Text
복사
이게 Copy Fail의 page cache write primitive다.

Step 5. memcpy(walk->addr, ...)가 실제 page cache byte를 바꾼다

Step 8의 scatterwalk_map_and_copy(..., out=1)만 보면 아직 추상적으로 보인다. 실제 memory write는 crypto/scatterwalk.c까지 내려가야 보인다.
include/crypto/scatterwalk.h에서 out=1memcpy_to_sglist()로 연결된다.
static inline void scatterwalk_map_and_copy(void *buf, struct scatterlist *sg, unsigned int start, unsigned int nbytes, int out) { if (out) memcpy_to_sglist(sg, start, buf, nbytes); else memcpy_from_sglist(buf, sg, start, nbytes); }
C
복사
memcpy_to_sglist()는 destination SGL의 start 위치에서 walk를 시작한다.
void memcpy_to_sglist(struct scatterlist *sg, unsigned int start, const void *buf, unsigned int nbytes) { struct scatter_walk walk; scatterwalk_start_at_pos(&walk, sg, start); memcpy_to_scatterwalk(&walk, buf, nbytes); }
C
복사
scatterwalk_start_at_pos()는 SGL entry들의 length를 빼면서 start가 속한 entry를 찾는다. vulnerable request에서는 RX entry의 길이를 모두 지나면 뒤에 chain된 TAG_SG에 도달한다.
req->dst RX SGL: length = 8 + target_offset -> chain TAG_SG: target file page P, file offset = target_offset, length = 4
Plain Text
복사
그 뒤 scatterwalk_map()이 현재 SG의 page와 offset을 kernel virtual address로 바꾼다.
static inline void scatterwalk_map(struct scatter_walk *walk) { struct page *base_page = sg_page(walk->sg); unsigned int offset = walk->offset; void *addr; if (IS_ENABLED(CONFIG_HIGHMEM)) addr = kmap_local_page(page) + offset_in_page(offset); else addr = page_address(base_page) + offset; walk->__addr = addr; }
C
복사
여기서 base_page는 앞에서 계속 따라온 target file의 page cache page P다. 마지막으로 memcpy_to_scatterwalk()가 그 주소에 4 byte를 쓴다.
to_copy = scatterwalk_next(walk, nbytes); memcpy(walk->addr, buf, to_copy); scatterwalk_done_dst(walk, to_copy);
C
복사
Copy Fail에서 이 호출의 인자는 다음과 같다.
buf = tmp + 1 nbytes = 4 walk->sg = TAG_SG sg_page = target file page P walk addr = page_address(P) + target_offset
Plain Text
복사
따라서 실제로 수행되는 동작을 의미상 한 줄로 쓰면 다음과 같다.
memcpy(page_address(target_file_page) + target_offset, controlled_aad_bytes_4_to_7, 4);
C
복사
이것이 file page cache가 실제로 overwrite되는 순간이다. VFS write()나 filesystem의 write_begin()/write_end() 경로를 타지 않고 crypto scatterwalk의 raw memcpy()로 page memory가 바뀐다. 따라서 정상 file write처럼 page dirty accounting과 writeback을 거쳐 disk를 수정하는 동작도 아니다.

target offset이 그대로 file offset이 되는 이유

exploit은 target offset을 t라고 할 때 다음 길이를 사용한다.
AAD length = 8 spliced file data = t + 4 authsize = 4 recv length = 8 + t
Plain Text
복사
TX의 논리적 배치는 다음과 같다.
[ AAD 8 ][ file bytes 0 .. t-1 ][ file bytes t .. t+3 ] ciphertext length t TAG length 4
Plain Text
복사
vulnerable algif_aeadoutlen = total_input - authsize = 8 + t만큼 RX에 복사하고, 남은 마지막 4 byte를 TAG_SG로 만든다.
RX length = 8 + t TAG_SG = page P, file offset t, length 4 req->dst = RX[8+t] -> TAG_SG[file offset t, 4 bytes]
Plain Text
복사
authencesn에서는 cryptlen -= authsize 이후 다음 위치에 4 byte를 쓴다.
scatterwalk_map_and_copy(tmp + 1, dst, assoclen + cryptlen, 4, 1);
C
복사
여기서 assoclen + cryptlen = 8 + t다. destination walk가 RX의 정확한 끝까지 이동한 뒤 다음 chained entry의 시작, 즉 TAG_SG의 file offset t에 도달한다.
또한 tmp는 destination의 처음 8-byte AAD를 읽은 값이다.
scatterwalk_map_and_copy(tmp, dst, 0, 8, 0);
C
복사
그러므로 little-endian 여부와 무관하게 memory byte 관점에서 tmp + 1은 AAD의 4..7번째 byte다. exploit이 AAD를 "AAAA" || new_bytes로 구성하는 이유가 이것이다.
AAD[0..3] = "AAAA" AAD[4..7] = overwrite할 4 byte authencesn tmp + 1 -> AAD[4..7] -> TAG_SG -> target file page cache의 file offset t
Plain Text
복사
예를 들어 t=0, new_bytes=0x90 0x90 0x90 0x90이면 file page의 0..3 byte가 바뀐다. 다음 반복에서 t=4를 사용하면 4..7 byte가 바뀐다. 이 과정을 4-byte chunk 단위로 반복해 필요한 patch를 page cache에 구성한다.

authentication 실패와 write 순서

직관적으로는 “decrypt/authentication이 실패하면 아무 일도 없어야 하는 것 아닌가?”라고 생각할 수 있다.
하지만 여기서는 write가 authentication 결과보다 먼저 발생할 수 있다. authencesn은 ESN 처리를 위해 sequence number 관련 값을 dst 쪽으로 옮겨 놓고 hash verification을 진행한다.
흐름상 중요한 부분은 다음이다.
crypto_authenc_esn_decrypt() scatterwalk_map_and_copy(..., dst, ..., out=1) ahash_request_set_crypt(...) crypto_ahash_digest(...) crypto_authenc_esn_decrypt_tail(...)
Plain Text
복사
즉 authentication이 최종적으로 실패하더라도, 그 전에 dst SGL을 통한 scratch write가 이미 page cache에 반영될 수 있다.
취약점은 crypto 결과를 정상적으로 받는 데 있는 게 아니라, 실패 가능한 operation 중간에 destination write가 먼저 일어난다는 데 있다.

patched algif_aead decrypt

patched 위치: crypto/algif_aead.c:65-230
patched 버전은 AEAD recv를 out-of-place 방식으로 바꾼다.
핵심 코드는 다음 형태다.
processed = used + ctx->aead_assoclen areq->tsgl_entries = af_alg_count_tsgl(sk, processed) areq->tsgl = sock_kmalloc(...) sg_init_table(areq->tsgl, areq->tsgl_entries) af_alg_pull_tsgl(sk, processed, areq->tsgl) tsgl_src = areq->tsgl rsgl_src = areq->first_rsgl.sgl.sgt.sgl memcpy_sglist(rsgl_src, tsgl_src, ctx->aead_assoclen) aead_request_set_crypt(src = tsgl_src, dst = RX_SGL, cryptlen = used, iv = ctx->iv)
Plain Text
복사
달라진 점은 명확하다.
vulnerable: src = RX SGL base dst = RX SGL base RX SGL 뒤에 TAG source page를 chain patched: src = per-request TX SGL dst = RX SGL source TAG page를 dst chain에 넣지 않음
Plain Text
복사
patched 메모리 상태는 다음이다.
TXREQ[0] -> page cache page [ AAD ][ CT ][ TAG ] RX[0] -> user output page [ AAD ][ CT ] req->src = TXREQ[0] req->dst = RX[0]
Plain Text
복사
authencesn이 여전히 req->dst에 scratch write를 해도 destination은 RX output buffer다.
req->dst walk RX[0] only
Plain Text
복사
source page cache page는 req->src에만 남는다. write side에 들어가지 않는다.

af_alg.c 패치 의미

패치는 algif_aead.c만 바꾸지 않았다. af_alg_pull_tsgl() API도 바꿨다.
vulnerable prototype:
unsigned int af_alg_count_tsgl(struct sock *sk, size_t bytes, size_t offset); void af_alg_pull_tsgl(struct sock *sk, size_t used, struct scatterlist *dst, size_t dst_offset);
C
복사
patched prototype:
unsigned int af_alg_count_tsgl(struct sock *sk, size_t bytes); void af_alg_pull_tsgl(struct sock *sk, size_t used, struct scatterlist *dst);
C
복사
offset이 사라졌다.
이 변경은 단순한 cleanup이 아니다. vulnerable AEAD decrypt가 TAG 부분만 TX SGL에서 따로 떼어 destination chain에 넣을 수 있었던 이유가 dst_offset이었다.
패치 후에는 다음 형태만 가능하다.
af_alg_pull_tsgl(sk, used, dst) TX SGL 앞에서부터 used byte를 소비한다. dst가 있으면 소비한 fragment를 그대로 dst로 옮긴다.
Plain Text
복사
즉 “중간부터 뒷부분만 골라 재사용”하는 동작이 사라진다.

skcipher diff는 부수 변경

crypto/algif_skcipher.c에도 변경이 있다.
vulnerable: af_alg_count_tsgl(sk, len, 0) af_alg_pull_tsgl(sk, len, areq->tsgl, 0) patched: af_alg_count_tsgl(sk, len) af_alg_pull_tsgl(sk, len, areq->tsgl)
Plain Text
복사
위치: crypto/algif_skcipher.c:137-157
이건 API signature 변경에 따른 caller 수정이다. skcipher 자체는 이미 다음처럼 source와 destination을 분리한다.
skcipher_request_set_crypt(src = areq->tsgl, dst = RX_SGL, len = len, iv = ctx->iv)
Plain Text
복사
Copy Fail의 핵심은 AEAD decrypt의 in-place SGL 구성이다.

patch 전후 diff 요약

로컬 diff 통계는 다음과 같다.
crypto/af_alg.c 10 insertions, 39 deletions crypto/algif_aead.c 19 insertions, 81 deletions crypto/algif_skcipher.c 3 insertions, 3 deletions include/crypto/if_alg.h 2 insertions, 3 deletions
Plain Text
복사
핵심 diff를 기능 단위로 보면 다음이다.
파일
변경
의미
crypto/algif_aead.c
decrypt/encrypt in-place SGL 구성 제거
source TX SGL과 destination RX SGL 분리
crypto/af_alg.c
af_alg_pull_tsgl()에서 dst_offset 제거
TX SGL 뒷부분만 destination처럼 재사용하는 API 제거
include/crypto/if_alg.h
prototype 변경
caller들이 offset 없는 API 사용
crypto/algif_skcipher.c
caller signature 수정
skcipher는 구조상 부수 변경
패치를 한 문장으로 줄이면 다음이다.
algif_aead가 page-cache-backed source SGL을 req->dst chain에 넣지 못하게 out-of-place operation으로 되돌린다.
Plain Text
복사

no-splice에서는 왜 성격이 달라지는가

로컬 probe에는 --no-splice 옵션이 있다.
/copyfail_probe /copyfail_probe --no-splice
Plain Text
복사
--no-splice에서는 MSG_SPLICE_PAGES를 쓰지 않고 일반 sendmsg 경로를 탄다.
일반 경로는 af_alg_sendmsg()에서 새 page를 만들고 memcpy_from_msg()로 복사한다.
input buffer -> AF_ALG private page -> TX SGL
Plain Text
복사
이 경우 취약한 SGL 구성 자체는 관찰할 수 있어도, TX source page가 target file의 page cache page가 아니다.
반대로 splice-backed path에서는 TX SGL이 file-backed page를 참조할 수 있다.
target file page cache -> TX SGL -> vulnerable dst chain -> authencesn write
Plain Text
복사
그래서 Copy Fail primitive를 이해할 때 MSG_SPLICE_PAGES/splice 계열이 중요하다.

page cache write가 disk write가 아닌 이유

Copy Fail로 바뀌는 것은 page cache page다.
file on disk original bytes page cache modified bytes
Plain Text
복사
이 차이 때문에 다음 현상이 가능하다.
read/exec/mmap path page cache를 먼저 볼 수 있음 reboot/drop cache modified page cache가 사라질 수 있음 file hash on disk 그대로일 수 있음
Plain Text
복사
즉 공격 관점에서는 persistence가 아니라 runtime memory view를 바꾸는 primitive에 가깝다.
하지만 setuid-root binary처럼 실행 시점의 page cache view가 권한 상승에 영향을 줄 수 있는 대상이면, disk write 없이도 LPE로 이어질 수 있다.

page cache write 이후의 LPE cashout

이하 execve() 분석은 page cache overwrite가 이미 끝난 뒤, 변조된 setuid binary를 어떻게 권한 상승에 사용하는지 설명하는 downstream 단계다. 취약점의 실제 write primitive는 위 Step 3부터 Step 9까지의 file page -> pipe_buffer -> bio_vec -> TX SGL -> dst chain -> scatterwalk memcpy 흐름에서 완성된다.
앞에서는 page cache write primitive까지만 봤다. 그런데 이게 왜 local privilege escalation으로 이어지는지는 execve()의 두 가지 성질을 같이 봐야 한다.
1. setuid 권한 전환은 file content byte가 아니라 inode metadata를 기준으로 결정된다. 2. 실행할 ELF text는 file-backed mmap/page fault 경로에서 page cache를 통해 들어온다.
Plain Text
복사
즉 공격자가 disk file 자체를 바꾸지 못해도, setuid-root binary의 page cache view를 바꾸면 다음과 같은 분리가 생긴다.
inode metadata owner = root mode = setuid + executable -> exec credential 계산에 사용됨 page cache content attacker-controlled 4-byte patch applied -> ELF text/data fetch에 사용될 수 있음
Plain Text
복사

1. setuid 권한은 inode mode/uid에서 계산된다

execve()fs/exec.cdo_execveat_common()으로 들어간다.
위치: fs/exec.c:1924-1931
SYSCALL_DEFINE3(execve, const char __user *, filename, const char __user *const __user *, argv, const char __user *const __user *, envp) { CLASS(filename, name)(filename); return do_execveat_common(AT_FDCWD, name, native_arg(argv), native_arg(envp), 0); }
C
복사
ELF loader가 실행을 확정하면 begin_new_exec()가 호출된다. 여기서 새 executable에 대한 credential 계산이 먼저 수행된다.
위치: fs/exec.c:1091-1098
int begin_new_exec(struct linux_binprm * bprm) { struct task_struct *me = current; int retval; /* Once we are committed compute the creds */ retval = bprm_creds_from_file(bprm); if (retval) return retval;
C
복사
bprm_creds_from_file()은 최종 실행 파일을 기준으로 bprm_fill_uid()를 호출한다.
위치: fs/exec.c:1583-1589
static int bprm_creds_from_file(struct linux_binprm *bprm) { struct file *file = bprm->execfd_creds ? bprm->executable : bprm->file; bprm_fill_uid(bprm, file); return security_bprm_creds_from_file(bprm, file); }
C
복사
bprm_fill_uid()가 보는 것은 file content가 아니라 inode다.
위치: fs/exec.c:1528-1577
static void bprm_fill_uid(struct linux_binprm *bprm, struct file *file) { struct inode *inode = file_inode(file); unsigned int mode; ... mode = READ_ONCE(inode->i_mode); if (!(mode & (S_ISUID|S_ISGID))) return; ... mode = inode->i_mode; vfsuid = i_uid_into_vfsuid(idmap, inode); vfsgid = i_gid_into_vfsgid(idmap, inode); err = inode_permission(idmap, inode, MAY_EXEC); ... if (mode & S_ISUID) { bprm->per_clear |= PER_CLEAR_ON_SETID; bprm->cred->euid = vfsuid_into_kuid(vfsuid); }
C
복사
여기서 S_ISUID는 mode bit다.
위치: include/uapi/linux/stat.h:17
#define S_ISUID 0004000
C
복사
그래서 target이 /usr/bin/passwd, /usr/bin/su, /usr/bin/sudo 같은 setuid-root binary라면 credential 계산은 대략 이렇게 된다.
inode->i_mode has S_ISUID inode->i_uid is root MAY_EXEC passes -> bprm->cred->euid = root
Plain Text
복사
이 credential은 이후 실제 task credential로 commit된다.
위치: fs/exec.c:1250-1256
/* install the new credentials for this executable */ security_bprm_committing_creds(bprm); commit_creds(bprm->cred); bprm->cred = NULL;
C
복사
즉 setuid-root binary를 execve()하는 순간, kernel은 binary의 inode metadata를 보고 euid를 root로 바꿀 준비를 한다. Copy Fail은 inode metadata를 바꾸는 취약점이 아니다. 오히려 metadata는 정상 setuid-root 그대로라서 권한 전환 조건이 유지된다.

2. ELF text는 file-backed mapping으로 올라온다

그 다음 봐야 할 것은 ELF loader다. load_elf_binary()는 실행 파일의 program header를 읽고, PT_LOAD segment를 process address space에 mapping한다.
위치: fs/binfmt_elf.c:832-869
static int load_elf_binary(struct linux_binprm *bprm) { ... struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf; ... if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) goto out; ... elf_phdata = load_elf_phdrs(elf_ex, bprm->file);
C
복사
PT_LOAD segment는 elf_load()를 통해 mapping된다.
위치: fs/binfmt_elf.c:1040-1055, fs/binfmt_elf.c:1188-1189
/* Now we do a little grungy work by mmapping the ELF image into the correct location in memory. */ for(i = 0, elf_ppnt = elf_phdata; i < elf_ex->e_phnum; i++, elf_ppnt++) { ... if (elf_ppnt->p_type != PT_LOAD) continue; elf_prot = make_prot(elf_ppnt->p_flags, &arch_state, !!interpreter, false); elf_flags = MAP_PRIVATE; ... error = elf_load(bprm->file, load_bias + vaddr, elf_ppnt, elf_prot, elf_flags, total_size);
C
복사
elf_load() 내부의 핵심은 elf_map()이고, elf_map()vm_mmap(filep, ...)로 file-backed mapping을 만든다.
위치: fs/binfmt_elf.c:370-399
static unsigned long elf_map(struct file *filep, unsigned long addr, const struct elf_phdr *eppnt, int prot, int type, unsigned long total_size) { unsigned long size = eppnt->p_filesz + ELF_PAGEOFFSET(eppnt->p_vaddr); unsigned long off = eppnt->p_offset - ELF_PAGEOFFSET(eppnt->p_vaddr); ... if (total_size) { total_size = ELF_PAGEALIGN(total_size); map_addr = vm_mmap(filep, addr, total_size, prot, type, off); ... } else map_addr = vm_mmap(filep, addr, size, prot, type, off);
C
복사
이 말은 ELF의 executable segment가 bprm->file을 backing file로 하는 VMA가 된다는 뜻이다. 실제 page가 CPU에서 execute되기 전에는 page fault를 통해 mapping된다.

3. file-backed page fault는 page cache를 먼저 본다

file-backed mapping에서 page fault가 나면 filemap_fault()가 호출된다.
위치: mm/filemap.c:3485-3529
/* filemap_fault - read in file data for page fault handling */ vm_fault_t filemap_fault(struct vm_fault *vmf) { struct file *file = vmf->vma->vm_file; struct address_space *mapping = file->f_mapping; pgoff_t max_idx, index = vmf->pgoff; struct folio *folio; ... /* Do we have something in the page cache already? */ folio = filemap_get_folio(mapping, index); if (likely(!IS_ERR(folio))) { ... } else { ... folio = __filemap_get_folio(mapping, index, FGP_CREAT|FGP_FOR_MMAP, vmf->gfp_mask);
C
복사
이미 page cache에 folio가 있으면 그 folio를 사용한다. 없으면 새 folio를 만들고 storage에서 읽어 온다.
마지막에는 fault 결과 page가 VMA에 들어간다.
위치: mm/filemap.c:3619-3631
/* Found the page and have a reference on it. */ max_idx = DIV_ROUND_UP(i_size_read(inode), PAGE_SIZE); if (unlikely(index >= max_idx)) { folio_unlock(folio); folio_put(folio); return VM_FAULT_SIGBUS; } vmf->page = folio_file_page(folio, index); return ret | VM_FAULT_LOCKED;
C
복사
여기서 Copy Fail이 만든 page cache write가 의미를 가진다.
Copy Fail before exec: target setuid binary의 page cache folio 일부를 4 byte patch execve target: inode metadata로 euid=root 계산 ELF PT_LOAD segment를 file-backed mmap page fault가 mapping->page cache folio를 찾음 patched folio가 process text view로 들어옴
Plain Text
복사
즉 disk file은 그대로라도, exec-time memory view는 변조된 page cache를 볼 수 있다.

4. LPE가 성립하는 지점

이제 두 흐름을 합치면 LPE 구조가 나온다.
attacker process, uid=1000 -> Copy Fail primitive로 setuid-root binary의 page cache 4 byte patch -> execve(setuid-root binary) kernel exec path -> bprm_fill_uid() inode->i_mode has S_ISUID inode->i_uid == root bprm->cred->euid = root -> commit_creds(bprm->cred) current task euid becomes root -> load_elf_binary() PT_LOAD segments mapped from bprm->file -> filemap_fault() finds already-modified page cache folio maps patched bytes into executable view result root credential + attacker-influenced code/data view
Plain Text
복사
중요한 점은 Copy Fail이 직접 commit_creds(init_cred) 같은 kernel control-flow hijack을 하는 취약점이 아니라는 것이다. 권한 상승은 정상적인 setuid exec credential path를 이용한다.
kernel memory corruption으로 cred를 직접 덮는 방식이 아님 X commit_creds(init_cred) gadget X kernel ROP X arbitrary kernel write to cred 정상 setuid exec를 이용하는 방식 O setuid-root inode metadata 유지 O page cache text/data view만 변조 O execve가 root credential을 부여
Plain Text
복사
그래서 target은 보통 다음 조건을 만족하는 파일이 된다.
1. attacker가 read 가능한 파일 2. root 소유 setuid executable 3. 실행 시 page cache view가 실제 instruction/data로 사용됨 4. 4-byte patch만으로 control-flow 또는 security decision을 바꿀 수 있는 지점이 있음
Plain Text
복사
공개 exploit이 setuid binary를 대상으로 삼는 이유가 여기에 있다. page cache write primitive 자체는 “파일 내용을 메모리에서 살짝 바꾸는 능력”인데, setuid-root exec path와 결합하면 “root credential로 변조된 실행 view를 실행하는 능력”이 된다.

5. 왜 파일 해시는 그대로일 수 있나

이 흐름에서 disk write는 발생하지 않는다. Copy Fail write는 authencesnreq->dst scatterlist를 따라 page cache folio에 들어간다.
normal file write: write syscall -> VFS write path -> page dirty accounting -> writeback -> disk content changes Copy Fail: AF_ALG/authencesn scratch write -> scatterlist points to page cache folio -> folio memory bytes change -> disk content may remain unchanged
Plain Text
복사
그래서 on-disk SHA256만 보면 정상인데, 같은 host에서 실행되는 process는 page cache의 변조된 bytes를 볼 수 있다. reboot, cache drop, file eviction 같은 이벤트가 발생하면 primitive 효과가 사라질 수 있는 것도 이 때문이다.

6. 한 줄 요약

Copy Fail의 LPE는 다음 문장으로 정리된다.
setuid-root binary의 권한은 inode metadata로 얻고, 실행될 byte는 page cache에서 가져오기 때문에, page cache write primitive만으로 root 권한에서 실행되는 code/data view를 바꿀 수 있다.
Plain Text
복사

Dirty COW와 차이점

Dirty COW와 Copy Fail은 둘 다 file-backed memory에 write 효과를 만들지만 root cause와 primitive 생성 방식은 다르다.
구분
Dirty COW
Copy Fail
원인
COW 처리 race
crypto source/destination SGL 구성 오류
주요 경로
madvise()/proc/self/mem
AF_ALG • 두 번의 splice()authencesn
성격
race condition
deterministic logic flaw
결과
file-backed page에 write 효과
page cache에 controlled 4-byte write
Dirty COW는 COW race를 이겨야 하지만, Copy Fail은 정해진 SGL 경로와 길이 계산을 따라가면 같은 4-byte write가 재현된다.

Mitigation

근본적인 해결은 취약한 kernel을 vendor가 제공하는 fixed kernel로 업데이트하는 것이다.
업데이트 전 임시 대응으로는 환경과 의존성을 확인한 뒤 algif_aead module load를 차단하거나 AF_ALG crypto user API의 노출을 줄이는 방법을 검토할 수 있다.
echo "install algif_aead /bin/false" | sudo tee /etc/modprobe.d/disable-algif_aead.conf sudo rmmod algif_aead 2>/dev/null
Shell
복사
AF_ALG를 실제 사용하는 workload가 있을 수 있으므로 운영 환경에서는 module 차단을 무조건 적용하지 말고 vendor guidance와 사용 여부를 먼저 확인해야 한다.

Reference

전체 call chain

최종적으로 call chain을 한 번에 쓰면 다음이다.
userspace socket(AF_ALG) bind("aead", "authencesn(hmac(sha256),cbc(aes))") setsockopt(ALG_SET_KEY) setsockopt(ALG_SET_AEAD_AUTHSIZE) accept() sendmsg(MSG_MORE, AAD 8 bytes) splice(target file -> pipe, target_offset + 4) splice(pipe -> AF_ALG opfd, target_offset + 4) -> kernel internally sets MSG_SPLICE_PAGES recvmsg(output) kernel alg_create() alg_bind() aead_bind() af_alg_accept() aead_accept_parent() aead_sendmsg() af_alg_sendmsg() -> extract_iter_to_sg() -> ctx->tsgl_list points to page cache page aead_recvmsg() _aead_recvmsg() -> vulnerable: RX SGL + TAG TX SGL chain -> aead_request_set_crypt(src=RX, dst=RX) crypto_aead_decrypt() crypto_authenc_esn_decrypt() -> scatterwalk_map_and_copy(..., dst, ..., out=1) -> write through req->dst
Plain Text
복사
patched에서는 _aead_recvmsg() 중간이 바뀐다.
_aead_recvmsg() -> patched: areq->tsgl = TX source -> dst = RX output -> no TAG source chain into dst
Plain Text
복사

디버깅할 때 볼 값

GDB로 보면 다음 값들이 중요하다.
break af_alg_sendmsg break _aead_recvmsg break af_alg_pull_tsgl break crypto_authenc_esn_decrypt break scatterwalk_map_and_copy
Plain Text
복사
af_alg_sendmsg()에서는 다음을 본다.
ctx->used ctx->aead_assoclen msg->msg_flags & MSG_SPLICE_PAGES ctx->tsgl_list first sg sg_page(sg) sg->offset sg->length
Plain Text
복사
_aead_recvmsg()에서는 다음을 본다.
used outlen processed as ctx->aead_assoclen areq->first_rsgl.sgl.sgt.sgl areq->tsgl rsgl_src tsgl_src
Plain Text
복사
vulnerable decrypt에서 확인할 포인트는 이거다.
RX SGL의 마지막 entry가 unmark되고 sg_chain(..., areq->tsgl)이 호출되는지
Plain Text
복사
crypto_authenc_esn_decrypt()에서는 다음을 본다.
req->src req->dst assoclen cryptlen authsize scatterwalk_map_and_copy(... out=1) target offset
Plain Text
복사

정리

Copy Fail은 AF_ALG, splice, scatterlist, authencesn이 각각 따로 위험해서 생긴 문제가 아니다.
문제는 source와 destination의 경계가 깨진 것이다.
source: file -> pipe -> AF_ALG splice로 들어온 page-cache-backed TX SGL (두 번째 splice 내부에서 kernel이 MSG_SPLICE_PAGES 설정) destination: recvmsg()가 제공한 RX output SGL
Plain Text
복사
이 둘은 분리되어 있어야 한다. 그런데 vulnerable algif_aead decrypt는 in-place 최적화를 위해 RX SGL 뒤에 source TAG SGL을 chain했다.
RX output SGL -> source TAG SGL
Plain Text
복사
그 결과 authencesnreq->dst에 쓰는 4 byte scratch write가 source page cache page에 도달했다.
패치는 이 구조를 없앤다.
patched: req->src = per-request TX SGL req->dst = RX SGL no source SGL in dst chain
Plain Text
복사
그래서 Copy Fail의 핵심 문장은 이렇게 정리할 수 있다.
AF_ALG AEAD decrypt의 in-place SGL 구성 때문에, splice로 들어온 page-cache-backed source page가 authencesn의 destination write 경로에 섞였다.
Plain Text
복사