题解作者:zzh1996
出题人、验题人、文案设计等:见 Hackergame 2024 幕后工作人员。
-
题目分类:general
-
题目分值:转账失败(150)+ 转账又失败(100)+ 转账再失败(200)
以下内容包含 AI 辅助创作
作为一名优秀的区块链开发者,你总是相信技术的力量。当你写出那个用于批量转账的智能合约时,内心充满了自豪——这将是一个完美的作品,将会帮助无数人省去逐笔转账的烦恼。
然而事情并没有那么简单。
第一次部署时,你发现有人在合约中设下了陷阱,只要你试图转账就会被残忍地拒绝。
「这算什么?」你不屑一顾,很快写出了新版本的合约:「现在即使有转账失败,其他地址也能正常收到款项」。
但你错了。那些神秘的地址们仿佛商量好了一般,用各种匪夷所思的方式阻挠你的转账,让你的合约陷入窘境。
在经历了无数次失败后,你终于明白:在区块链的世界里,每一个看似简单的转账,都可能隐藏着一个精心设计的陷阱。而现在,你必须找出这些陷阱背后的真相。
AI 辅助创作部分结束
你可以通过 nc 202.38.93.141 10222
来连接,或者点击下面的「打开/下载题目」按钮通过网页终端与远程交互。
如果你不知道
nc
是什么,或者在使用上面的命令时遇到了困难,可以参考我们编写的 萌新入门手册:如何使用 nc/ncat?
这道题前两问比较简单,最后一问考察了一个在智能合约开发中很少有人关注但是却真的会导致一些问题的点。
在以太坊的智能合约模型下,当我们去调用一个智能合约的函数的时候,其实会进行一个 Message Call,这个 Message Call 的 input data 是这次调用的函数签名和参数,value 是这次调用附带的转账金额。当你给一个地址转账的时候,本质上也是对转账的目标地址进行了一个 Message Call,只不过这个 Message Call 只有 value,而 input data 是空串而已。当一个智能合约被调用的时候,它的字节码会被执行,执行的过程中每条指令都会消耗一定量的 gas。当 gas 消耗完的时候,或者智能合约主动选择回滚(即执行 REVERT 指令)的时候,这个 Message Call 就会失败。如果执行失败,从调用者的角度看,这个 CALL 的返回值就是 0,而成功的时候是 1。同时,无论成功还是失败,Message Call 都可以返回一个字节串,一般来说成功时表示返回的数据,失败时表示报错信息。这个字节串会保留在调用者的 return data 缓冲区里面,调用者可以使用 RETURNDATASIZE 和 RETURNDATACOPY 指令来取出。如果是给非智能合约地址转账,因为地址没有字节码,所以会直接成功。
第一问要求转账失败即可,直接在智能合约的 receive 函数中 revert 即可。receive 函数指的就是智能合约接收到普通转账(即 input data 为空的时候)会执行的逻辑。
解题代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Receiver {
receive() external payable {
revert();
}
}
这里补充一下,使用一个空的 Solidity 合约代码也是可以通过的,因为不写 receive 函数的话,默认的行为就是,收到转账的时候会回滚。
至于如何把智能合约编译成题目要求输入的字节码,你可以参考题目附件提供的 compile.py
,也可以在 Remix IDE 编译后点击复制 Bytecode 的按钮。
第二问题目增加了对某个地址转账失败时继续给其他地址转账的逻辑,也就是说通过直接的 revert 操作,只会让批量转账合约跳过你这个地址,不会满足输出 flag 的要求。要得到 flag,我们得让整个批量转账合约执行失败。这一小问的解法就是把批量转账合约的 gas 耗尽即可。如果没有直接想到这点,其实通过对比第二问和第三问的代码也很容易发现,因为第三问代码相比第二问唯一的改动就是在转账的时候限制了一下 gas。
解题代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Receiver {
receive() external payable {
while(gasleft() > 100) {}
}
}
如果想深入这一小问的细节的话,其实智能合约在调用另一个智能合约的时候,只会传递剩余 gas 的 63/64。所以一次转账的时候接收者耗尽了 gas,并不会让批量转账合约也立即就耗尽 gas。但是,由于这题是 10 次转账,每次都会把剩下的 gas 传递过去,而合约调用本身又会消耗不少的固定 gas,所以很快就会导致批量转账合约的 gas 耗尽。
最后一问就比较有趣了。既然限制了 gas 消耗,并且当转账失败的时候跳过,那还能怎么搞呢?实际上,这一小问考的是「returnbomb attack」。具体来说,Solidity 语言的行为是,即使你没有用到一个底层调用返回的数据,编译出来的字节码也会把这些数据复制到内存中。这里要注意,智能合约互相调用时每次调用是独立的内存空间,消耗的 gas 也只是根据当前合约的内存用量来决定的。而智能合约的 gas 消耗是内存使用量的二次函数,所以当 10 次转账的接收合约都返回尽可能大的字节串的时候,会导致批量转账合约消耗过多的 gas。如果之前完全没了解过这种攻击,也可以通过研究 EVM 的指令 并且阅读题目合约的汇编指令的方式,来思考所有转账接收合约可以对批量转账合约造成的影响,并且找到如何让批量转账合约失败。
在这道题中,返回多少长度的内容,才能让转账接收合约在自己不要耗尽 gas 的前提下,尽可能消耗批量转账合约的 gas 呢?比较暴力的方法就是写个脚本来不断增加长度穷举一下。更好的方法是直接使用公式来计算。
智能合约的 gas 消耗跟内存使用量的关系是:
memory_size_word = (memory_byte_size + 31) / 32
memory_cost = (memory_size_word ** 2) / 512 + (3 * memory_size_word)
转账的接收合约初始的时候会有提供的 10000 gas 加上 CALL 指令在 value 不为 0 时额外提供的 2300 gas,一共 12300 gas。根据上述公式反向求解一下,稍微往低估计一些,大概允许转账的接收合约有 59200 字节的内存使用量。
解题代码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Receiver {
receive() external payable {
assembly {
return(0, 59200)
}
}
}
其中 return 指令的两个参数表示内存的起始位置和长度。在 EVM 中,使用内存时,内存会自动扩张,而扩张的时候会自动消耗掉新的内存使用量相比之前增加的 gas。
另外,其实把 return 改成 revert 可以让批量转账合约消耗更多的 gas,因为 revert 的时候批量转账合约会额外修改一个变量。但是,解出这道题只用 return 就够了。
在很多实际的应用场景中,会有类似的逻辑。例如说,一个跨链桥把很多跨链请求打包起来,在目标链上进行批量的转账操作,就要防止花费过多的 gas fee 以及防止某个转账导致整个交易失败。
往年的题目都是用 Geth 来运行一个以太坊节点的,今年换成了 Foundry 工具链中的 Anvil,又快又省内存,还不需要复杂的配置。Foundry 这套东西用于本地开发和调试真的很方便。