1. Casino
Goal
require(IERC20(wNative).balanceOf(address(casino)) == 0);
Solution
合约模拟了一个老虎机的运行,玩家扮演赌徒,初始状态下玩家有1个wNative Token
,而赌场有1000
个,目标就是清空赌场手里的1000
个wNative
代币。
有了目标,我们就可以来看一下代码是如何实现的赌场逻辑的:
function play(address token, uint256 amount) public checkPlay {
_bet(token, amount);
CasinoToken cToken = isCToken(token) ? CasinoToken(token) : CasinoToken(_tokenMap[token]);
// play
cToken.get(msg.sender, amount * slot());
}
function slot() public view returns (uint256) {
unchecked {
uint256 answer = uint256(blockhash(block.number - 1)) % 1000;
uint256[3] memory slots = [(answer / 100) % 10, (answer / 10) % 10, answer % 10];
if (slots[0] == slots[1] && slots[1] == slots[2]) {
if (slots[0] == 7) {
return 100;
} else {
return 10;
}
} else if (slots[0] == slots[1] || slots[1] == slots[2] || slots[0] == slots[2]) {
return 3;
} else {
return 0;
}
}
}
function _bet(address token, uint256 amount) internal {
require(isAllowed(token), "Token not allowed");
CasinoToken cToken = CasinoToken(token);
try cToken.bet(msg.sender, amount) {}
catch {
cToken = CasinoToken(_tokenMap[token]);
deposit(token, amount);
cToken.bet(msg.sender, amount);
}
}
玩家首先调用play()
函数参加赌博,play()
会调用_bet()
通过burn
玩家输入的token
(我们可以想象成筹码)来实现下注的操作,随后调用slot()
函数(也就是开始摇动老虎机),老虎机的判断逻辑是,首先通过block.number
获取一个随机数(伪随机),如果三个数字都是7
那么会翻100
倍,但是如果是7
之外的数字,比如出来的是666,555,444
只会翻十倍,两个数字一样翻三倍,其余的结果翻两倍。
其实看到这里我就判断解决这道题目的关键就是block.number
,因为伪随机数漏洞有点太明显,可以通过观看PatrickAlphaC的视频课程进一步的了解Weak Randomnes
。简单来说在区块链中不存在真正意义上的随机数,因为链上的一切都是公开的,题目中使用的blocknumber
也是一个可被预测和计算的数字,所以我们能够通过计算block.number
来得知我们在哪一个block
进行下注能够一定得到777
的最好结果。
现在我们找到了一条能够让我们赌注翻一百倍的方式!但是我们的初识赌注只有1 wNative
,就算计算出一次777
,翻一百倍我们也只能获取到100个token
,但是我们的目标有整整1000
个!我们得想想办法。
后面我把目光放在了_bet()
函数中:
function _bet(address token, uint256 amount) internal {
//检查是否是允许的underlying Token
require(isAllowed(token), "Token not allowed");
CasinoToken cToken = CasinoToken(token);
//如果直接调用失败(可能是因为token是原始代币而不是CasinoToken),则:从_tokenMap获取对应的CasinoToken地址调用deposit函数将原始代币转换为CasinoToken然后再调用CasinoToken的bet函数
try cToken.bet(msg.sender, amount) {}
catch {
cToken = CasinoToken(_tokenMap[token]);
deposit(token, amount);
cToken.bet(msg.sender, amount);
}
}
起初我在这里没有看到问题,但是后面我把目光放在了underlying token
的实现中:
contract WrappedNative is ERC20("Wrapped Native Token", "WNative"), Ownable {
using Address for address payable;
fallback() external payable {
deposit();
}
function mint(address receiver, uint256 amount) external onlyOwner {
_mint(receiver, amount);
}
function deposit() public payable {
_mint(msg.sender, msg.value);
}
function withdraw(uint256 amount) external {
_burn(msg.sender, amount);
payable(msg.sender).sendValue(amount);
}
}
try…catch
的作用是如果报错那么执行catch{}
中的内容,但是如果不返回报错呢?
我们可以看到在wNative
的实现中有一个fallback()
函数,fallback
会在调用合约不存在的函数时触发,详细内容可以查看WTF的文章,这下我们的解题思路就明确了。
- 调用
play()
函数,传入wNative token
和大于0的amount
,这里我们可以传入一个500 - 然后使用循环也好或者爆破也可以,找到一个返回非0的
block.number
- 最后直接调用
withdraw
即可直接取出所有的wNative
- 后面发现也不需要管
block.number
,直接在调用play的时候传入一个足够大的数字,什么5k
,1w
的都可以,然后直接withdraw
就可以了
2. WBC
Goal
function solve() public override {
require(wbc.scored());
super.solve();
}
Solution
首先阅读代码,要让wbc.scored=true
有两种方式:
1. 成功执行_homeBase()
2. block.timestamp % 23_03_2023 == 0
因为第二个几乎不可能,所以我们走第一条路成功调用_homeBase()
代码模拟的是一个棒球比赛,homeBase
也就是本垒打,我们要先越过一垒,二垒和三垒,所以我们就一个一个的绕过。
首先我们要注册成为Player
,调用bodyCheck()
function bodyCheck() external {
require(msg.sender.code.length == 0, "no personal stuff");
require(uint256(uint160(msg.sender)) % 100 == 10, "only valid players");
player = msg.sender;
}
bodyCheck()
检查了msg.sender.code.length == 0
,我们可以通过在constructor
中调用的方式来绕过,因为智能合约在执行构造函数的阶段code.length
为0。
还有一个检查是要求uint256(uint160(msg.sender)) % 100 == 10
,一个特定的地址,在EVM
中创建地址有两种一种是Create
一种是Create2
,具体可查看这篇文章即可了解原理如何生成一个我们想要的地址:Vanity-address.
接下来我们要调用ready()
, 进入比赛
function ready() external {
require(IGame(msg.sender).judge() == judge, "wrong game");
_swing();
}
通过这个我们可以知道caller
必须是一个合约,同时要满足IGame
接口,并且合约的judge()
返回值==judge
。
接下来就进入了swing()
function _swing() internal onlyPlayer {
_firstBase();
require(scored, "failed");
}
swing()
会调用_firstBase()
function _firstBase() internal {
uint256 o0o0o0o00oo00o0o0o0o0o0o0o0o0o0o0o0oo0o = 1001000030000000900000604030700200019005002000906;
uint256 o0o0o0o00o0o0o0o0o0o0o0ooo0o00o0ooo000o = 460501607330902018203080802016083000650930542070;
uint256 o0o0o00o0oo00oo00o0o0o0o0o0o0o0o0oo0o0o = 256; // 2^8
uint256 o0oo0o0o0o0o0o0o0o0o00o0oo00o0o0o0o0o0o = 1;
_secondBase(
uint160(
o0o0o0o00oo00o0o0o0o0o0o0o0o0o0o0o0oo0o
+ o0o0o0o00o0o0o0o0o0o0o0ooo0o00o0ooo000o * o0o0o00o0oo00oo00o0o0o0o0o0o0o0o0oo0o0o
- o0oo0o0o0o0o0o0o0o0o00o0oo00o0o0o0o0o0o
)
);
}
_firstBase()
函数会讲计算之后的结果传递提_secondBase()
,我们来看看_secondBase()
干了什么:
function _secondBase(uint160 input) internal {
require(IGame(msg.sender).steal() == input, "out");
_thirdBase();
}
要求传入的值和我们的攻击合约的steal
返回值是一样的,才能调用三垒_thirdBase()
, 这没什么难度计算一下就好,我们来看三垒的实现:
function decode(bytes32 data) external pure returns (string memory) {
assembly {
mstore(0x20, 0x20)
mstore(0x49, data)
return(0x20, 0x60)
}
}
function _thirdBase() internal {
require(keccak256(abi.encodePacked(this.decode(IGame(msg.sender).execute()))) == keccak256("HitAndRun"), "out");
_homeBase();
}
_thirdBase
调用了攻击合约的execute
函数,并且用wbc::decode
函数对他进行解码,要求计算后的keccak256
的值得和HitAndRun
一致,所以我们来看看decode
函数在干什么具体在干嘛。
我们可以从evm.codes上看到:
Stack input
offset: offset in the memory in bytes.
value: 32-byte value to write in the memory.
第一个参数是memory中的offset,第二个参数是值
所以decode的的作用就是将data转换成EVM格式的字符串:
assembly {
mstore(0x20, 0x20)
//在内存中0x20的位置写入值0x20
mstore(0x49, data)
//在内存中0x49的位置写入data
return(0x20, 0x60)
//从0x20的位置返回0x60长度的值
}
// x 代表我们的data
0x20 0000000000000000000000000000000000000000000000000000000000000020 0x3f
0x40 000000000000000000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx 0x5f
0x60 xxxxxxxxxxxxxxxxxx0000000000000000000000000000000000000000000000 0x7f
我们知道在EVM
中字符串会被打包成三个32
字节,第一个32
字节是偏移量代表字符串从哪里开始,第二个32
字节是字符串的长度,第三个是字符串的实际内容。所以我们只要传入HitAndRun
的长度和实际内容将他们嵌进去就好了。所以我们可以通过传入:0x09(HitAndRun的长度)
和486974416E6452756E
(HitAndRun的ASCII码)就可以了:
0000000000000000000000000000000000000000000009486974416e6452756e
我们把它嵌入到上面的内存中看看:
// x 代表我们的data
0x20 0000000000000000000000000000000000000000000000000000000000000020 0x3f
0x40 0000000000000000000000000000000000000000000000000000000000000009 0x5f
0x60 486974416e6452756e0000000000000000000000000000000000000000000000 0x7f
这个也就代表着我们的字符串HitAndRun
了
到此我们成功绕过了前面的三垒,接下来就差最后一个_homebase()
了
function _homeBase() internal {
scored = true;
(bool succ, bytes memory data) = msg.sender.staticcall(abi.encodeWithSignature("shout()"));
require(succ, "out");
require(
keccak256(abi.encodePacked(abi.decode(data, (string)))) == keccak256(abi.encodePacked("I'm the best")),
"out"
);
(succ, data) = msg.sender.staticcall(abi.encodeWithSignature("shout()"));
require(succ, "out");
require(
keccak256(abi.encodePacked(abi.decode(data, (string))))
== keccak256(abi.encodePacked("We are the champion!")),
"out"
);
}
_homeBase()
会通过staticcall
调用攻击合约的shou()
函数两次,那我们怎么让同一个函数在两次调用的时候返回不一样的值呢,在两次调用的时候只有一个东西发生了变化,就是gas
,我们可以通过一笔交易中gas
的剩余来判断这是第一次还是第二次调用,从而完成条件到此我们已经达成了完成这道题目的所有必要条件:
constructor
中调用bodycheck
成为player
judge()
返回block.coinbase
steal()
返回execute()
返回0000000000000000000000000000000000000000000009486974416e6452756e
shout()
函数加一个判断gas
剩余的if
判断,然后返回不同值- 写出EXP
Exp
3. WarRoomNFT
Goal
function isSolved(address user) external view returns (bool) {
return _balances[user] > 1000 ether;
}
Solution 没啥好说的, 重入从而下溢
4. Arcade
Goal
function solve() public override {
require(IERC20(arcade).balanceOf(you) >= 200, "Unsolved");
super.solve();
}
Solution
event
里面可以偷钱
emit PlayerChanged(_redeem(oldPlayer), _setNewPlayer(newPlayer));