This was a long challenge with multiple parts. There were 3 separate challenges in one, with another flag for completing 2 or more of them. It contains 3 different cryptography challenges all with interesting solutions. There's a lot to learn here, so strap in!
The Challenge
We get a single challenge.py
script with all the logic. If we just run it we can see a menu:
*====================================================*
| .\ /. |
| { ~O~ } VEGETATION CART MANUAL OVERRIDE SYSTEM |
| '/_\' *using modern 3-factor encryption* |
| \ | / |
| \|/ v0.28 ALPHA (c) 1998 |
| |
| Vegetation status: PARCHED |
| Water system: CRASHED, needs reboot |
| Authentication status: 0/2 factors |
| |
| 3-FACTOR AUTHENTICATION STATUS |
| 1 [XX] md-512 secure pincode |
| 2 [XX] length-5-XOR challenge-response |
| 3 [XX] checksum madness |
| |
*====================================================*
Please use 1-3 to authenticate, use 0 to reboot the water system or use 99 to exit.
>
The text is displayed a bit slow as well, with a sort of typing animation. So trying brute-forcing anything on the server is not a good idea. We also get to choose one of 3 authentication methods. Every time we solve one of these challenges the "Authentication status" goes up by one.
Challenge 1: md-512 secure pincode
The first option just asks us:
Please enter the md-512 secured pin code.
We can look at the source code to see where it comes from:
def factor_1():
global FACTOR_1_DONE
type_print('Please enter the md-512 secured pin code.')
# Secret md512 hash of the PIN. DO NOT SHARE WITH HACKERS!
SECURE_PIN_HASH = '2a31ee819074ae22fb16780317363b14'
# Receive user input.
pin = get_input(length=4)
pin = str(pin).encode('utf-8')
assert len(pin) == 4
# Our patented md512 algorithm. DO NOT SHARE WITH HACKERS!
for _ in range(512):
pin = hashlib.md5(pin).hexdigest().encode()
# Check if the PIN is correct.
if pin.decode() == SECURE_PIN_HASH:
type_print("PIN correct. Verification token: " + FLAG_1)
FACTOR_1_DONE = True
else:
hacker_detected()
It accepts a 4-digit number and verifies it with the SECURE_PIN_HASH
. We can see that this hash is hardcoded into the code, with a comment saying "DO NOT SHARE WITH HACKERS!". We also see that the PIN we input is hashed 512 times with the md5 hashing algorithm. This is algorithm is notoriously insecure, mostly because it's so quick to compute. Even doing it 512 times would only take about half a millisecond on my machine. We know it's a 4-digit PIN, which only has 10000 possibilities. This means it should only take about 5 seconds to brute force every possible code with the hash.
So that's exactly what I did. Just take the SECURE_PIN_HASH
and the algorithm, and try it with every code until it matches the hash.
import hashlib
import itertools
real_hash = b"2a31ee819074ae22fb16780317363b14"
digits = [str(d) for d in range(10)] # '0'-'9'
for pin in ["".join(item) for item in itertools.product(digits, repeat=4)]: # '0000'-'9999'
hashed = pin.encode() # Make copy
for _ in range(512): # Hash 512 times
hashed = hashlib.md5(hashed).hexdigest().encode()
if hashed == real_hash: # If found
print(pin)
break
After about 3 seconds of running the script, we get the code: 7331
(probably not a coincidence that it's 1337
backward). When we try the code in the challenge it lets us through and we get the first flag!
CTF{md512_1s_the_n3w_md5}
Challenge 2: length-5-XOR challenge-response
When solving these challenges, I actually didn't manage to solve this specific challenge in time for the event, but of course, I solved it instantly the next day. I got stuck on a dumb mistake I'll explain what went wrong at the end.
The second option gives us a challenge:
Please decrypt the following challenge to verify you own a challenge-response device.
Challenge:1726724a1664006b001d0b136b005e6442046e1e6700570200202d47020d2100516e1c65155c4513
We again need to input the correct response to get the flag. Looking at the code we see this:
def factor_2():
global FACTOR_2_DONE
type_print('Please decrypt the following challenge to verify you own a challenge-response device.')
# Retreive encrypted challenge.
CHALLENGE = FLAG_2_ENCRYPTED.hex()
type_print('Challenge: ' + CHALLENGE)
# Get user input.
response = get_input(length=len(FLAG_2))
assert len(response) == len(FLAG_2)
# Check if response is correct.
if response == FLAG_2:
type_print("Challenge-response validated.")
FACTOR_2_DONE = True
else:
hacker_detected()
We don't get a lot of info. We see that the challenge number we got is the FLAG_2_ENCRYPTED
variable where we can't see where it comes from. The check is just verifying the real FLAG_2
, so we can't see any crypto logic here.
The challenge is named "length-5-XOR" though, so we can expect that the FLAG_2_ENCRYPTED
is encrypted with an XOR cipher and a key of length 5. This is obviously not long enough for the whole flag, so it means the key is repeated for the length of the flag. So if the key was secret
we would get secretsecretsecretsecretsecret
.
There are a few attacks we can do with XOR. The first is using the fact that the key repeats to try one letter and using statistics to see how good the resulting letters are. The problem is that this technique only really works with bigger pieces of text.
Another attack is using a known-plaintext, meaning we know some part of the plaintext and can get the key for the rest of the text. This is more likely the case here since we know the flag starts with CTF{
and ends with }
.
XOR is completely symmetric, meaning the same steps are taken to encrypt and decrypt. So if we XOR the plaintext with the key, we get the ciphertext, and if we XOR the ciphertext with the key we get the plaintext. But most importantly, if we XOR the ciphertext with the plaintext, we get the key! For example, here's how we XOR a plaintext (pt) with a key into the ciphertext (ct):
pt: 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 = "Hello, world!"
key: 73 65 63 72 65 74 73 65 63 72 65 74 73 = "secretsecrets"
---------------------------------------------- XOR
ct: 3b 00 0f 1e 0a 58 53 12 0c 00 09 10 52
Now if we only had the plaintext and ciphertext, we could get the key back like this:
pt: 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 = "Hello, world!"
ct: 3b 00 0f 1e 0a 58 53 12 0c 00 09 10 52
---------------------------------------------- XOR
key: 73 65 63 72 65 74 73 65 63 72 65 74 73 = "secretsecrets"
The reason this works is that XOR just flips bits, and this operation just flips them back.
We can now use this knowledge to get the key. Since we know the flag starts with CTF{
, we can just XOR the ciphertext with the plaintext CTF{
and we get the first 4 characters of the key.
To get the last 5th character of the key, we can use the fact that the flag ends with }
. If we count the number of characters in the ciphertext, we can see that it is exactly 40 characters long. This means the last character would be in index 39, which is one less than a multiple of 5. Our key had a length of 5 so the }
character matches with the last character of the key we need! We can now make a simple python script that does this for us:
challenge = bytes.fromhex("1726724a1664006b001d0b136b005e6442046e1e6700570200202d47020d2100516e1c65155c4513")
def xor(key, pt):
return bytes([a ^ b for a, b in zip(pt, key)])
# Get start of key
start = b"CTF{"
key = xor(start, challenge[:4])
# Get end of key
end = b"}"
key += xor(end, challenge[-1:])
# Decrypt flag, repeating the key to fit
flag = xor(challenge, key*10)
print(flag)
This first gets the starting 4 characters, then the last 5th character using the end of the flag. We can then just decrypt the challenge data with the found key and get the flag!
CTF{x0r_1s_a_10000_p3rc3nt_s3cure_r1ght}
So what went wrong? Like I said I didn't solve this challenge during the event due to a dumb mistake. In the solve script, I had
start
set toctf{
instead ofCTF{
. I forgot that the flag format was with capital letters, so I used lowercase instead. This made it so the bits were slightly off and I got something that only closely looked like the flag:b'ctf{x\x10R\x7f1s\x7fA\x7f10\x10\x10\x10_p\x13RC3nT\x7fS3cURE_r\x11GHt}'
. I was very confused at this point why it wasn't working and eventually gave up. The next day I realized I had the wrong flag format and it instantly worked.
Challenge 3: checksum madness
If we just select option 3 we get some sort of checksum. We then again get an input where we need to supply the correct answer.
Please decipher the following challenge using our numeric checksum.
Your checksum is:7522152654707300386186439523978546463817632956485370843579652698381042658687526962018889
Looking at the python code we can see the following function:
def factor_3():
global FACTOR_3_DONE
type_print('Please decipher the following challenge using our numeric checksum.')
# Our fantastic checksum generation process.
numbers = [ord(c) for c in FLAG_3]
checksum = b''
for i, n in enumerate(numbers):
assert n >= 52 and n <= 125
n -= 52
checksum += chr(n).encode()
checksum = int.from_bytes(checksum, 'big')
type_print('Your checksum is: ' + str(checksum))
response = get_input(length=len(FLAG_3))
assert len(response) == len(FLAG_3)
if response == FLAG_3:
type_print("Checksum validated.")
FACTOR_3_DONE = True
else:
hacker_detected()
It seems to encrypt the flag in some way. First, it turns all characters of the flag into their ASCII values and saves them in numbers
. Then in a loop through all these values, it subtracts 52 from each value and saves it in checksum
as a character again. Finally, the checksum is converted into an integer from the characters in the checksum. This converting int.from_bytes()
function is not very common, and I looked up what it did. It's pretty simple with the 'big'
argument:
It takes all bytes from the checksum and appends them together. Then it treats all the bytes together as a big-endian integer.
So in this example, the string "Hello" turns into 310939249775. Luckily this means there is also a simple way back:
In Python we can use a simple function like this to convert our integer back into bytes:
def int_to_bytes(n):
length = (n.bit_length() + 7) // 8 # Bits to bytes rounding up
return n.to_bytes(length, 'big')
print(int_to_bytes(310939249775)) # b'Hello'
Then we just need to reverse the last step of subtracting 52 from each character. We can simply add 52 again to get back the original value. Both these steps together in a script look something like this:
def int_to_bytes(n):
length = (n.bit_length() + 7) // 8 # Bits to bytes rounding up
return n.to_bytes(length, 'big')
# Checksum from challenge
encrypted = int_to_bytes(7522152654707300386186439523978546463817632956485370843579652698381042658687526962018889)
# Add back 52 to each byte
flag = b""
for c in encrypted:
c += 52
flag += bytes([c])
print(flag)
This will reverse the bytes from the number, and add the 52 to each character. In the end, it prints the flag!
CTF{chEcksUms_EveryWHere_fOr_tha_wIn}
Final Challenge: Humans not Allowed
The challenge also has an extra feature in the menu selection. You can choose 0
to "reboot" the system. This won't actually reset the challenge, but it requires 2 authentication factors to be completed. If we solve any two of these by specifying the correct answer, we get asked a math question:
Please use 1-3 to authenticate, use 0 to reboot the water system or use 99 to exit.
> 0
Authentication complete.
Due to company policy, humans are not allowed to operate the manual override as humans are imprecise and incapable of operating the system.
Please prove that you are not human.
What is [109 + 542]?
>
Looking at the Python source code for this we see that it will ask us 512 times. We definitely don't want to do this manually, so we need to write a script to solve this.
def reboot_water_system():
global FACTOR_1_DONE, FACTOR_2_DONE, FACTOR_3_DONE
amount = sum([FACTOR_1_DONE, FACTOR_2_DONE, FACTOR_3_DONE])
if not amount >= 2:
type_print("Please authenticate using 2 factors of the modern 3-factor technology first.")
return
else:
type_print("Authentication complete.")
type_print("Due to comapny policy, humans are not allowed to operate the manual override as humans are inprecise and incapable of operating the system.")
type_print("Please prove that you are not human.")
for _ in range(512):
a = randint(0,999)
b = randint(0,999)
print(f"What is [{str(a)} + {str(b)}]?", flush=True)
answer = int(get_input(length=4))
if not (answer == a + b):
hacker_detected()
return
type_print("Authentication complete. Rebooting watering system...")
type_print("You saved the plants :)")
# Prints some awesome ASCII art as a bonus (no flag here) - try to make it visible even with automated tools :)
type_print(TXT_PLANTS_SAVED)
# Prints the final flag yay!
type_print(FLAG_4)
quit()
At the end of the loop, it prints some "Authentication complete" messages so we know when we need to stop and display the output to us. So let's write a script that first solves 2 of the 3 challenges, and then does all 512 questions. Since we already know the answer to the first 2 challenges, we can hardcode them into our script. Then we need to loop and extract the question from the output. We need to add up the two numbers and send back the result. Finally, if we get the "Authentication complete" message, we can stop the loop and display the output with the flag.
To make the automated connection to the server I used pwntools
. Which is normally used for Binary Exploitation challenges. But it's also very useful for making a script that interacts with a TCP server (where you manually use nc
). Combining all of the above into a script it looks something like this:
import re
from pwn import *
import sys
r = remote("portal.hackazon.org", 17001)
r.sendline(b"1") # Solve first challenge
r.sendline(b"7331")
r.sendline(b"3") # Solve third challenge
r.sendline(b"CTF{chEcksUms_EveryWHere_fOr_tha_wIn}")
r.sendline(b"0") # Reboot water system
r.recvline_contains(b"Please prove that you are not human")
# Answer 512 math questions
while True:
line = r.recvline()
if b"Authentication complete" in line: # If done, stop
# Had some issues with the ASCII art, but directly writing it to stdout seems to work
sys.stdout.buffer.write(r.recvall())
break
# Answer question
code = re.findall(rb"What is \[(\d+) \+ (\d+)\]", line)[0]
answer = int(code[0]) + int(code[1])
print(f"{int(code[0])} + {int(code[1])} = {answer}")
r.sendline(str(answer).encode())
You saved the plants :)
▒▒▒▒▒ ▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒
▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒
▒▒▒▒▒▒▒▓▓▒▒▒▒▒▒
▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒
▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒
▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒
▒▒▒▒▒▒▒ ▓▒▒▒▒▒▒▒
▒▒▒▒▒ ▓ ▒▒▒▒▒
▒▒▒ ▓ ▒▒▒
▓
▓▓▓ ▓
▓▓▓▓▓▓ ▓
▓▓ ▓▓
▓ ▓ ▓▓▓▓▓
▓ ▓▓▓▓▓▓
▓▓ ▓▓
▓ ▓
▓
████████████
█████████
█████████
███████
███████
██████
████
This runs for a bit solving all the questions, and finally prints the beautiful ASCII art, but most importantly the flag!
CTF{y0u_s4v3d_th3_pl4nts_n3v3r_lose_th3_docs}
Conclusion
This was a collection of 3 interesting cryptography challenges. First, we brute-forced a hash, then broke an XOR cipher, and finally, we reversed a simple encryption algorithm. Altogether this was a good learning experience with different crypto techniques. I hope you learned something as well.