May 27, 2025

Pwnable.kr - col

This was a fun one. Let’s dive in:

Daddy told me about cool MD5 hash collision today.
I wanna do something like that too!

ssh col@pwnable.kr -p2222 (pw:guest)

I SSH into the server, and find three files to work with:

col@ubuntu:~$ ls -l
total 24
-r-xr-sr-x 1 root col_pwn 15164 Mar 26 13:13 col
-rw-r--r-- 1 root root      589 Mar 26 13:13 col.c
-r--r----- 1 root col_pwn    26 Apr  2 08:58 flag

The setup looks similar to a few other problems I have worked on: col is a SETUID binary that unlocks the flag.

Let’s try running the binary

col@ubuntu:~$ ./col
usage : ./col [passcode]

It appears that the binary is asking for the passcode as an argument. Let’s check out the source code from col.c:

#include <stdio.h>
#include <string.h>

unsigned long hashcode = 0x21DD09EC;
unsigned long check_password(const char* p){
    int* ip = (int*)p;
    int i;
    int res=0;
    for(i=0; i<5; i++){
        res += ip[i];
    }
    return res;
}

int main(int argc, char* argv[]){
    if(argc<2){
        printf("usage : %s [passcode]\n", argv[0]);
        return 0;
    }
    if(strlen(argv[1]) != 20){
        printf("passcode length should be 20 bytes\n");
        return 0;
    }
  
    if(hashcode == check_password( argv[1] )){
        setregid(getegid(), getegid());
        system("/bin/cat flag");
        return 0;
    }
    else
        printf("wrong passcode.\n");
    return 0;
}

After examining the source code, it appears that to solve this challenge, I need to find a 20-byte string that collides with the hash code 0x21DD09EC, as processed by the check_password function.

After some back and forth with GPT about how to reverse the math in the check_password function, this script was suggested to find a suitable string. I’ve had mixed results with code generated by AI, but let’s see how it works this time.

def find_passcode():
    target = 0x21DD09EC
    # Using a simple approach: find integers that sum to the target.
    # Splitting into five equal parts
    part = target // 5

    # Check for potential overflow
    if part > 0xFFFFFFFF:
        print("Each integer part must be less than 0xFFFFFFFF")
        return
 
    # Create 5 integers from the part
    # Build the byte representation
    integers = [part] * 4
    integers.append(target - sum(integers[:4]))  # Calculate last integer to fill the sum
    passcode_chars = bytearray()
    for i in integers:
        # Converting each integer into bytes
        passcode_chars += i.to_bytes(4, 'little')  # Little-endian
  
    # Make sure that the total length is exactly 20 bytes
    if len(passcode_chars) != 20:
        print("Error: Passcode must be 20 bytes long, got", len(passcode_chars))
        return
    # Print the characters that make up the passcode
    print("Constructed Passcode (bytes):", passcode_chars)
    print("Passcode String (not null-terminated):", passcode_chars.decode(errors='ignore'))
  
find_passcode()

The output for this script:

$ python3 find_col.py
Constructed Passcode (bytes): bytearray(b'\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xcc\xce\xc5\x06')
Passcode String (not null-terminated):
ÈÎÅÈÎÅÈÎÅÈÎÅÌÎÅ

The “Passcode String” looks like some flavor of cursed, so I’m just going to ignore that last part. The bytearray above looks like it could work. I tried using echo to pipe the bytestring to the binary col but it didn’t work.

col@ubuntu:~$ echo -e -n '\xc8\xce\xc5\xc8\xce\xc5\xc8\xce\xc5\xc8\xce\xcd\xc8\xce\xc5' | ./col
usage : ./col [passcode]

Then I tried using printf, with similar results.

col@ubuntu:~$ workprintf '\xc8\xce\xc5\xc8\xce\xc5\xc8\xce\xc5\xc8\xce\xcd\xc8\xce\xc5' | ./col
usage : ./col [passcode]
workprintf: command not found

Looks like piping the string into the binary isn’t going to work. Looking back at the source code reminds me that the string needs to be passed as an argument from the command line. After some more searching and trial and error, I find a working solution.

col@ubuntu:~$ ./col "$(printf '\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xc8\xce\xc5\x06\xcc\xce\xc5\x06')"
Two_hash_collision_Nicely

This method worked because the 20 bytes were passed directly as argv\[1], matching the expected input of 20 bytes, and allowing the binary to unlock the flag.