Alphascii Clashing

INTRODUCTION

This challenge was super easy - I saw the vulnerability right away. Instead of just finishing it quickly, I thought I’d get around to doing something that has been on my To-Do list for far too long:

Finally learning to use Pwntools for CTFs 💀

Usually, I just write socket code. However, I’ve noticed that this is clearly the “slow” way to do things. If I’m going to go faster in future CTFs, I’ll need to use some libraries to speed up my coding!

The challenge itself is a simple menu with two/three pages to it:

  • The main menu
  • Registration
  • Login

To traverse the menus, we need to write everything in JSON format, which is a little tedious.

This inconvenience is ultimately what convinced me I should script it up with Pwntools. So… thanks?

menu entry

CODE ANALYSIS

🚫 If all you wanted to see was a clean example of using Pwntools for CTFs, skip to the Solution section.

We can broadly observe this by skimming the code:

  • Oddly enough, the “database” hashes usernames, not passwords 🤔
  • During Registration, the username and password must be alphanumeric.

That tells us a little about how the application works, but the actual vulnerability is in the Login part of the code:

creds = json.loads(input('enter credentials (json format) :: '))
usr, pwd = creds['username'], creds['password']
usr_hash = md5(usr.encode()).hexdigest()
for db_user, v in users.items():
    if [usr_hash, pwd] == v:
        if usr == db_user:
            print(f'[+] welcome, {usr} 🤖!')
        else:
            print(f"[+] what?! this was unexpected. shutting down the system :: {open('flag.txt').read()} 👽")
            exit()
        break
else:
    print('[-] invalid username and/or password!')

Read the code backwards, starting with how we obtain the flag:

  1. To read the flag, we must follow the else branch of the if usr = db_usr check.
  2. To reach that branch, the usr_hash and pwdmust be stored in the “database” already (Registered as a user)
  3. usr_hash is the MD5 hash of the username that we provide.

Do (1) and (2) seem like a contradiction? Well, normally we think of hashes as being 1-to-1 with a password, but by definition a hash is more of a many-to-1 mapping onto a particular value.

Therefore, we just need to two usernames that produce the same hash - this is called a hashing collision.

While discussing crypto stuff, the words “possible” and “feasible” are very distinct. It’s always possible to crack a hash, but is it feasible? Depending on the preimage of the hash, it may not be feasible to crack it!

What about a hashing collision? This is a different problem, because we simply need to find any two preimages that produce the same hash. This can still be quite difficult, but is a lot more feasible than cracking the hash outright.

This is made much more difficult by the constraint we observed earlier: the username(s) must be alphanumeric!

I went searching for a way to find MD5 hashing collisions and stumbled across some work by the person who ended up being the OG researcher around hashing collisions: Marc Stevens.

Check out the Github repo for this project: https://github.com/cr-marcstevens/hashclash

💡 Note the name, “hashclash”. That’s too obvious to be a mere coincidence!

Scrolling down the README, we find that the author has conveniently posted two alphanumeric strings that produce a hashing collision! Thank you! 🙏

md5("TEXTCOLLBYfGiJUETHQ4hAcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak")
=
md5("TEXTCOLLBYfGiJUETHQ4hEcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak")

Pwntools

The researcher has done our work for us (finding two alphanumeric strings that produce a hashing collision), so this section will just be about coding a solution in Pwntools.

Connecting

I think pwntools has a thing for this, but I’ll just use sys to parse args:

#!/usr/bin/env python3

from pwn import *
import sys

# Parse args
if len(sys.argv) < 2:
    print(f'Usage: {sys.argv[0]} "<IP>:<PORT>"')
    sys.exit(1)

# Connect to target
(host,port) = sys.argv[1].split(":")
print(f'Connecting to {host}:{port}')
conn = remote(host, port)

Like many similar challenges, this one comes with a copy of the server code. We can use Pwntools to connect to a running process just as easily as a network socket - Let’s change the code to conditionally connect to the *local python process if desired:

# Connect to target
if ":" in sys.argv[1]:
    (host,port) = sys.argv[1].split(":")
    print(f'Connecting to {host}:{port}')
    conn = remote(host, port)
elif "server.py" in sys.argv[1].lower():
    conn = process(['python3', './server.py'])

Receiving from the remote

We have a few different methods available to us, each with their own specialty:

Pwntools always uses bytes-encoded objects instead of strings. You’ll understand the convenience of that clarity if you do a few Pwn challenges.

Keep the the following at your fingertips:

# Encode string to bytes and manually tack on a newline character
'hello_world'.encode() + b'\n'
# Decode bytes to string. Good for printing
b'im a byte string!\n'.decode('utf-8')
# Receive a fixed number of bytes
recv(numb=4096, timeout=default)  bytes

# Receive bytes until the first newline character
recvline(keepends=True, timeout=default)  bytes

# Receive bytes until the first occurrence of the specified string
recvuntil(delims, drop=False, timeout=default)  bytes

# Receive ALL bytes, until the EOL is reached and connection closes. Good for the end of a script.
recvall(timeout=Timeout.forever)  bytes

Sending to the remote

Sending is even easier. Just remember to manually add in the newline character if your target application is running a console-like app:

# Send a string
conn.send(b'hello world')
# Using encode()
s = 'hello world'
conn.send(s.encode())

SOLUTION

With the Pwntools basics out of the way, we can write the whole challenge solution in one simple script:

#!/usr/bin/env python3

from pwn import *
import json
import sys
from hashlib import md5

# https://github.com/cr-marcstevens/hashclash
username_orig = 'TEXTCOLLBYfGiJUETHQ4hAcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak'
username_coll = 'TEXTCOLLBYfGiJUETHQ4hEcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak'

# Parse args
if len(sys.argv) < 2:
    print(f'Usage: {sys.argv[0]} "<IP>:<PORT>"')
    sys.exit(1)

# Connect to target
if ":" in sys.argv[1]:
    (host,port) = sys.argv[1].split(":")
    print(f'Connecting to {host}:{port}')
    conn = remote(host, port, level='error')
elif "server.py" in sys.argv[1].lower():
    conn = process(['python3', './server.py'], level='error')

def choose_menu_item(s):
    print(conn.recvuntil(b':: ').decode('utf-8'))
    msg = json.dumps({"option":s}).encode()+b'\n'
    print(">> ",msg)
    conn.send(msg)
    
def submit_json_creds(username,password):
    print(conn.recvuntil(b':: ').decode('utf-8'))
    msg = json.dumps({
        "username": username,
        "password": password
        }).encode()+b'\n'
    print(">> ",msg)
    conn.send(msg)
    
choose_menu_item('register')
submit_json_creds(username_orig, 'password123')
choose_menu_item('login')
submit_json_creds(username_coll, 'password123')

# recvall() didnt want to work here:
try:
    while True:
        print(conn.recvline().decode('utf-8'))
except EOFError:
    sys.exit(0)

solved

😂 Oh, I guess it was on Twitter too? Oh well, still found it in the end!


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake