ACS 2023: rustarm

find plain text

TL;DR

Simple string encryptor written in Rust with known character set and XOR key.

Challenge Overview

For this challenge, we are given a Rust binary and an encoded secret that may potentially be our encrypted flag.

After playing around with the output, we observed the following behaviour:

  1. If user input does not start the flag format, an error message Invalid input format is thrown.

  2. The Rust program encounters a length error whenever our input length is <5 characters.

  3. When there are no characters between the curly brackets, no encrypted text is outputted.

Based on the observations above, it's safe to deduce that our flag length is 23 characters (excluding flag format) since there are 23 hex values in the provided enc file.

Solution 1: Decoding Values Manually

Validating Input Format

At the start of the main function, there is a nested if statement that checks local_58 against the flag format ACS{}. Based on this, we know that local_58 is our user-controlled input.

Furthermore, this portion of code in the main function shifts our user input forward by 4 bytes, which explains why the starting flag format ACS{ is not included in the encryption. At this point, we can also safely assume that the ending bracket } will be excluded from the encryption.

Non-Null Terminated Strings

Next, we can find a bunch of strings that are not null-terminated in Rust.

To clear these up for better readability. We can undefine all these data sections with Right Click -> Clear Code Bytes, then redefine them in a character array of their respective size like char[23].

XOR Operation

Further down, we find a suspicious XOR operation that involves 2 operands. To understand where the operands originated from, we will trace the variables using Ghidra's Slice Highlighting feature.

Tracing Operand 1: uVar3

Right Click -> Highlight -> Forward Operator Slice

uVar3 -> local_a8

Right Click -> Highlight -> Backward Operator Slice

local_a8 -> local_e8

From this, we know that the first XOR operand is the 23-long string ACSISACEANCYBERSECURITY.

Tracing Operand 2: uVar4

Right Click -> Highlight -> Forward Operator Slice

uVar4 -> local_158

Right Click -> Highlight -> Backward Operator Slice

local_158 -> local_58 (Recall that we have previously identified local_58 as our user-controlled input)

Hypothesis 1

The fact that the string ACSISACEANCYBERSECURITY is the same length as our flag makes it a good candidate for the XOR encryption key. A classic way to verify this is by having the key XOR itself; and if the result is all zeroes, we know that the key is valid.

Based on that, we now know that the program is doing a simple character-by-character XOR like the following pseudocode:

for i in range(23):
    output[i] = input[i] ^ key[i]

However, when attempting to reverse this logic, we get several corrupted characters - Perhaps there's more twist to it?

Hypothesis 2

Looking back, I realized there's a character set [0-9a-zA-Z\D\W] initialized as local_d0, which has yet to be referenced anywhere.

At this point, I was wondering what kind of additional manipulation could've been applied to both our user input and the ACSISACEANCYBERSECURITY key, which then leads me to the following logic:

for i in range(23):
    output[i] = alphabet.index(input[i]) ^ alphabet.index(key[i])

The reason why I knew the alphabet.index() function is needed for both the input and key operand, is because it would be impossible to get all zeroes when XORing the key by itself in the case where alphabet.index() is applied only on either one of the operands.

Reversing the logic would then give us the following pseudocode:

for i in range(23):
    input[i] = alphabet[output[i] ^ alphabet.index(key[i])]

Flag: ACS{cR0s$_C0mpi1e_wi7h_Rust}

Final Script

#!/usr/bin/python3

alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()-_=+"
output = bytes.fromhex("2c171c245d43000e3a24202323472130092757002a2b15")
key = [alphabet.index(c) for c in "ACSISACEANCYBERSECURITY"]

assert(len(output) == len(key))

flag = ""
for e, k in zip(output, key):
    flag += alphabet[e ^ k]
print("ACS{" + flag + "}")

Solution 2: Brute Forcing

Another behaviour that I noticed was the encryption is performed independently of each position. This means that, unlike in a typical Hash Diffusion scenario, any wrong guesses attempted will not alter other parts of the ciphertext. This behaviour alone reduces the number of guesses (premutations) required to brute force the flag.

To export the character set with a breeze, I used convert_to_python_list.py from LazyGhidra to convert it into a Python list.

Then, I wrote a simple brute-force script to compare the results.

Flag: ACS{cR0s$_C0mpi1e_wi7h_Rust}

Final Script

#!/usr/bin/python3

from pwn import *

exe = "./rustarm"
elf = context.binary = ELF(exe, checksec=False)

context.clear(arch="amd64")
context.log_level = 'CRITICAL'
warnings.filterwarnings(action='ignore', category=BytesWarning)

char_set = [
    0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 
    0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D, 0x4E, 0x4F, 0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 
    0x57, 0x58, 0x59, 0x5A, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x6B, 0x6C, 
    0x6D, 0x6E, 0x6F, 0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x21, 0x40, 
    0x23, 0x24, 0x25, 0x5E, 0x26, 0x2A, 0x28, 0x29, 0x2D, 0x5F, 0x3D, 0x2B
]

enc = "2c171c245d43000e3a24202323472130092757002a2b15"
global password
password_length = 23

def main():
    password = "ACS{"

    for i in range(password_length):
        for char in char_set:
            io = elf.process()
            io.recvuntil(b'plain text: ')

            candidate_password = password + chr(char) + chr(125) # closing curly bracket
            io.sendline(candidate_password)
            print("Trying: ", candidate_password)

            io.recvuntil(b'encrypt text: ')
            output = io.recvline()[:-1].decode()

            if enc.startswith(output):
                print("Password Found:", candidate_password)
                password = password + chr(char)
                break

            io.close()
            time.sleep(0.15)

if __name__ == "__main__":
        main()

Last updated