Just Rust – ångstromCTF 2020

by RageKnify

Points: 170 (Dynamic, 47 solves)

Description: Clam really enjoys writing in C, but he realizes that he’ll have to learn another language eventually. After all, he’s grown pretty rusty after only programming in C for a year. So, he decided to write a program to create some ASCII art from user input. He also provided a sample output!

Given: program, flag_output

Solution

TL;DR

  1. Try to reverse

  2. Give up on Reversing Rust

  3. Identify which output bits each char affects

  4. Brute-force the flag char by char until each bit has the target value

Exploit in solve.py

Description

We are given a program that asks for 32 characters and prints out some ASCII art based on the input.

$ ./just_rust
What do you want to encode?
asdf
Input must be 32 characters.
$ ./just_rust
What do you want to encode?
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OOOOOOOO
@@@@@@@@
@@@@@@@@
@@@@@@@@
@@@@@@@@
OOOOOOOO
OOOOOOOO
@@@@@@@@

We also have the desired output with the flag redacted:

$ ./just_rust
What do you want to encode?
[REDACTED]
CCHJEHMK
CFKJCEOL
FOJLMOJJ
BDN@H@BA
ODMJHFCJ
MOOKMOOO
OOAOFOGI
@@@@@@@@

I opened Ghidra to try to Reverse the program, but the first 5 lines of main were:

  habe1453c04c6104c(local_1e8,&PTR_s_What_do_you_want_to_encode?_Cann_00338de8,1,8,0);
  _print(local_1e8);
  h980c23501ad67776(&local_1b8);
  local_188 = stdin();
  read_line(local_1a0,&local_188,&local_1b8,read_line);
  hfa3ac7a45b0a87ed(local_1a0,0x12bfff,0x12);
  h8846cd2964247bd9(&local_188);

Seeing those weirdly named functions I closed Ghidra and decided to do it Black Box.

Playing around I noticed that changing one char of the input only changed a few chars of the output:

$ ./just_rust
What do you want to encode?
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
OOOOOOOO
@@@@@@@@
@@@@@@@@
@@@@@@@@
@@@@@@@@
OOOOOOOO
OOOOOOOO
@@@@@@@@
$ ./just_rust
What do you want to encode?
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab
OOOOOOOG
H@@@@@@@
@@@@@@@@
@@@@@@@@
@@@@@@@@
OOOOOOOO
OOOOOOOO
@@@@@@@@

Solution

I wrote a python script to try to brute-force the flag char-by-char.

Try an input

from subprocess import Popen, PIPE

def attempt(inp):	# Returns output of a given input in a string
    p = Popen("./just_rust", stdin=PIPE, stdout=PIPE)
    res = p.communicate(str.encode(inp))[0]
    res = res.split(b'\n')[1:-1]
    res = ''.join([x.decode() for x in res])
    return res

Find which bits differ between 2 strings

def tobits(s):	# Stolen from some StackOverflow post
    result = []
    for c in s:
        bits = bin(ord(c))[2:]
        bits = '00000000'[len(bits):] + bits
        result.extend([int(b) for b in bits])
    return result

def calc_diffs(string1, string2):
    res = set()
    bits1 = tobits(string1)
    bits2 = tobits(string2)
    for i in range(len(bits1)):
        if bits1[i] != bits2[i]:
            res.add(i)
    return res

Find which bits are affected by each char we don’t know yet

possible_chars = string.ascii_lowercase + string.ascii_uppercase + '0123456789_'
flag = "actf{" + "?"*26 + "}"
default = attempt(flag)
affs = {}
for i in range(5, 5+26):	# chars whose values we don't know
    changed = set()
    for c in possible_chars:
        flag_try = flag[:i] + c + flag[i+1:]
        result = attempt(flag_try)
        diffs = calc_diffs(default, result)
        changed.update(diffs)
    affs[i] = changed

At this point we can check whether our theory holds:

for key in affs:
	bits = list(affs[key])
	bits.sort()
	print(bits)
[47, 119, 191, 199, 271, 343, 415]
[55, 127, 135, 207, 279, 351, 423]
[63,  71, 143, 215, 287, 359, 431]
[6,   78, 150, 222, 294, 366, 438]
[14,  86, 158, 230, 302, 374, 446]
[22,  94, 166, 238, 310, 382, 390]
[30, 102, 174, 246, 318, 326, 398]
[38, 110, 182, 254, 262, 334, 406]
[46, 118, 190, 198, 270, 342, 414]
[54, 126, 134, 206, 278, 350, 422]
[62,  70, 142, 214, 286, 358, 430]
[5,   77, 149, 221, 293, 365, 437]
[13,  85, 157, 229, 301, 373, 445]
[21,  93, 165, 237, 309, 381, 389]
[29, 101, 173, 245, 317, 325, 397]
[37, 109, 181, 253, 261, 333, 405]
[45, 117, 189, 197, 269, 341, 413]
[53, 125, 133, 205, 277, 349, 421]
[61,  69, 141, 213, 285, 357, 429]
[4,   76, 148, 220, 292, 364, 436]
[12,  84, 156, 228, 300, 372, 444]
[20,  92, 164, 236, 308, 380, 388]
[28, 100, 172, 244, 316, 324, 396]
[36, 108, 180, 252, 260, 332, 404]
[44, 116, 188, 196, 268, 340, 412]
[52, 124, 132, 204, 276, 348, 420]

Yes, none of the otput bits are affected by 2 different chars of the input.

Brute-force chars one by one:

# Removed `\n`s, they don't matter
flag_res = "CCHJEHMKCFKJCEOLFOJLMOJJBDN@H@BAODMJHFCJMOOKMOOOOOAOFOGI@@@@@@@@"

for i in range(5, 5+26):
    for c in possible_chars:
        flag_try = flag[:i] + c + flag[i+1:]
        result = attempt(flag_try)
        wrong = calc_diffs(flag_res, result)

		# Check that only bits not controlled by this char are wrong
        if wrong.isdisjoint(affs[i]):
            flag = flag_try
            break
print(flag)

Running that we get the flag: actf{b1gg3r_4nd_b4dd3r_l4ngu4g3}

I went to submit the flag and found out I got first blood, so I decided it would be my first writeup.

The challenge author, Aplet123, posted the source code here.

Resources:

Exploit in solve.py