BCACTF: reload decode

Challenge

Author: Nikhil

Points: 50 points

Solves: 19

Category: misc

Description:

Greetings neighbor. My name’s Crazy Dave but you can just call me Crazy Dave. I found this weird website that seems to represent something. Says something about taco as a reward Can you find whatever the code is and hand it to me? I could really go for a taco right now

Resources

  • Web servers: challs.bcactf.com:30831
  • Static resources: main.py

challs.bcactf.com:30831

A website with a /flag endpoint whose response is an ASCII art of a flag represented using only 1s and 0s:

011000
100000    011010110011
011000    011001011100110011011111
000100    011001101110011010111011111011101110
001100    000001011101110110000011011101010010011000000001
010101    011100000010001110110011001001010111011000110011001011010101
010010    000000001111011101011101000000011001010001111000110011011010
111111    101011011101011000111011000001110111011010001000
001010    001000011000100000001011101110010100
110010    011110101000010110011001
110010    001000011110
101010
011110
101110
010010
011000
010010
001100
011100
000100
011001
100110
011000
100110
010100
110111
010011
101010
011100
000100
010110
101110
011110
011101

main.py

from flask import Flask, render_template
import random

app = Flask(__name__)

@app.route('/')
def index():
    return render_template('index.html')

@app.route('/flag')
def getFlag():
    # Get flag from flag.txt
    with open("flag.txt") as f:
        flag = bytearray(f.read().encode())

    flag_str = ""
    for b in flag:
        b = b << 4 ^ ((b << 4 & 0xff) >> 4)
        bm = 1 << random.randint(0, 11)
        cb = b ^ bm
        flag_str += bin(cb)[2:].zfill(12)
        
    return render_template('index.html', joined_flag = flag_str)

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

Initial thoughts and Analysis

In the main.py file, we can see that the only endpoint that returns any relevant information is the /flag route. I don’t like the look of the getFlag() function, though.

The getFlag() function reads the contents of flag.txt and transforms it into a bytearray before saving it to the flag variable. Each character of the flag goes through a sequence of bitshifts and XORs before they are stored in flag_str. The most important bit here is that each character of the flag is bitshifted left by a random amount between 0 and 11 (random.randint(0, 11)). This means that a single character in the flag plaintext can turn into 12 different outputs as random.randint is inclusive:

    def randint(self, a, b):
        """Return random integer in range [a, b], including both end points.
        """
        return self.randrange(a, b+1)

When the entire flag has been transformed/encrypted it is displayed on the website in a series of <h6> tags which together form an ascii art flag.

Vulnerability

The vulnerability in this challenge lies in the fact that we can “encrypt” any input we want using a modified version of the same algorithm. I’ve created a function that will create a dictionary that lets me look up a plaintext character given an encrypted character:

def create_lookup() -> dict[str, list[str]]:
    printable_range = range(0x20, 0x7f)

    lookup = {}
    for character in printable_range:
        modified_character = character << 4 ^ ((character << 4 & 0xff) >> 4)

        for i in range(0, 12):
            random_shift = 1 << i
            possible_output = bin(modified_character ^ random_shift)[2:].zfill(12)

            if possible_output not in lookup:
                lookup[possible_output] = []
            lookup[possible_output].append(character)

    return lookup

Exploitation

The only tools I needed to use for this challenge were me, myself, and my trusty snake (python).

Initially, I copied and manually formatted the binary output of http://challs.bcactf.com:30831, but that ended up being too time consuming, and too prone to errors. Resorted to fetching the contents of the website, and collecting all the binary data with BeautifulSoup.

I managed to get what I thought to be the correct flag multiple times, however due to a bitshift added to the ciphertext, the resulting plaintext was different each time. These were some of the invalid flags I got:

bcactf{n0w-HEr3's-@-taco-chbnRzIHzIHpvbWJp}
bcactf{n0w-HEr3's-@-taco-xhbnRzIHzIHpvbWJp}
bcactf{n0w-HEr3's-@tco-cGhbnRzIHZzIHpbWJp}
bcactf{n0w-HEr3s-@-taco-cGxhbnRzIHZzIHpvbJpZ}
bcactf{n0w-HEr3's-@-taco-cGxbRzIHZzIHpvbWJpZ}

I tweaked the script to compare multiple different plaintexts, and generate a new plaintext containing only the most common letter at each position.

Final solve script

# Function to fetch the binary representation of
# the encrypted flag from the ascii art flag from
# the `/flag` endpoint
def get_bins(count: int) -> list[str]:
    from requests import get
    from bs4 import BeautifulSoup as bs

    bins = []
    for _ in range(count):
        response_text = get("http://challs.bcactf.com:30831/flag?").text
        soup = bs(response_text, "html.parser")
        # All the binary data are stored in <h6> tags
        splits = soup.find_all("h6")
        string = "".join([s.text for s in splits])
        bins.append(string)
    return bins


def create_lookup() -> dict[str, list[str]]:
    printable_range = range(0x20, 0x7f)

    lookup = {}
    for character in printable_range:
        modified_character = character << 4 ^ ((character << 4 & 0xff) >> 4)

        for i in range(0, 12):
            random_shift = 1 << i
            possible_output = bin(modified_character ^ random_shift)[2:].zfill(12)

            if possible_output not in lookup:
                lookup[possible_output] = []
            lookup[possible_output].append(character)

    return lookup


# Get 5 versions of the encrypted flag
bins = get_bins(5)
# Create a lookup containing all possible ciphertext <-> plaintext combinations
lookup = create_lookup()

i = 0
chunk_size = 12
streams = []

for i in range(len(bins[0])//chunk_size):
    for i_bins in range(len(bins)):
        chunk = bins[i_bins][i * chunk_size: (i + 1) * chunk_size]

        try:
            letters = [chr(character) for character in lookup[chunk]]
        except KeyError:
            continue

        if i_bins == 0:
            streams.append([])
        streams[-1] += letters

# Get the most common letter at each index
# and collect them to get the flag
for s in streams:
    counts = {}
    for a in s:
        if a in counts:
            counts[a] += 1
        else:
            counts[a] = 1
    res = [key for key in counts if all(counts[temp] <= counts[key] for temp in counts)]
    print(" ".join(res))

# The output will be a vertical list of each character in the flag
# Some indices may have multiple possible characters

# Example:
# b
# c t
# a c
# c
# t
# f
# {
# etc.

The output of this script is the flag:

bcactf{n0w-HEr3's-@-taco-cGxhbnRzIHZzIHpvbWJpZ}