起因
在日常的链上浏览中,笔者留意到一个Verified Contract。其中有了 5 ETH(价值约 $10,183)的资金。令人警觉的是,该高净值合约的公开交互记录极为异常,仅存在一条历史交易。
分析
基本信息
首先给出合约相关信息:
合约地址:0xf327CC34562ac0857C2Ff033Ce8D8B3713725cFD
Etherscan:https://etherscan.io/address/0xf327cc34562ac0857c2ff033ce8d8b3713725cfd
合约代码:
1 | /** |
代码分析
合约实现:一个竞猜合约,admin可以设置一个问题和答案,参与者可以通过try函数提交答案,
首先看全局变量有三个:
1 | string public question:存储admin提出的问题 |
再看合约代码只有4个函数
1 | function Try(string memory _response) public payable |
逐个对函数逻辑进行分析:
1 | function Try(string memory _response) public payable { |
可以说是整个合约最重要的一个函数,首先判断了msg.sender == tx.origin,阻止外部合约调用,防止闪电贷,然后就是判断传入的_response是否与存储的responseHash相等,如果相等而且转入的以太币多于1 ether就把整个合约的钱都转给调用者。该函数仅设置了条件成立时的转账逻辑,在未通过校验时没有任何信息给出
1 | function Start( |
Start函数为设置问题,如果当前responseHash为0,则设置responseHash为答案的hash,问题则直接改为新提交的问题
1 | function Stop() public payable isAdmin { |
Stop函数:admin可以随时任意结束游戏,存在极大安全风险
1 | function New( |
New函数:admin可以随时任意更改question和答案hash,存在极大安全风险
1 | constructor(bytes32[] memory admins) { |
构造器中设置admin账户有哪些,将admins数组中的每一个设置true,这里提交的bytes32应是属于admin address的hash
1 | modifier isAdmin() { |
检查是否为管理员
对于以上代码逻辑,没有设置任何的event事件
链上分析
由于区块链上一切信息都是公开的,我们可以直接从链上查到问题和答案为:


inputdata:0xc76de3e90000…[省略]…206c657474655220572000…
问题为:”What is at the end of a rainbow?”
答案为:” letteR W “
按照常规逻辑,调用者只需构造 Try(" letteR W ") 即可提取合约余额。然而,笔者基于 Anvil Fork 主网状态进行本地测试时,发现交易虽被成功打包(Status: Success),但未触发任何底层 CALL 转账指令。
显然是没有进入if的逻辑,显然问题就出现在responseHash == keccak256(abi.encode(_response)。虽然responseHash是非public的状态变量,但是一切数据都是存储在链上,依然可以用storageAt来查看,由于这里是bytes32变量,位于第二个位置,所以直接查slot 1
1 | slot 0 -> question (string) |

得到了0xf152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd3

letteR W 却是得到的0x9f16d1bc522fb263ce6d2593130671f3038b388331c87fa555be65b8979e3513
- 链上 slot 1 = 0xf152950b…
- keccak256(abi.encode(“ letteR W “)) = 0x9f16d1bc… 两者不等 → responseHash 在 Start() 之后被修改过
调用new初始化过responseHash,导致Start修改并未生效
交易Hash:0x62a1c781701c93b4d7a2aace3c42cf75ec540078ff6753824ab65a67a0806b12
说明链上的responseHash其实是被修改了
既然公开的 Start函数调用 交易并未修改状态,那么真实的 responseHash 是何时被注入的?通过深挖交易执行栈(Call Stack),笔者在 Etherscan 的 Advanced Mode 中提取到了一笔隐蔽的Internal Transaction。

inputData:
1 | 0x9278a35a000000000000000000000000f327cc34562ac0857c2ff033ce8d8b3713725cfd000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000084ed8df1640000000000000000000000000000000000000000000000000000000000000040f152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd30000000000000000000000000000000000000000000000000000000000000020576861742069732061742074686520656e64206f662061207261696e626f773f00000000000000000000000000000000000000000000000000000000 |
这就说的通了0xf152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd3的缘由
本地模拟过程:
- 启动anvil:
1 | anvil --fork-url https://eth-mainnet.g.alchemy.com/v2/token-xxxxxx |
- 切换到本地账户:
1 | cast rpc anvil_impersonateAccount 0x038405665520AC945D26eB7936ffe0115B2f2BBd \ --rpc-url http://127.0.0.1:8545 |
- 给自己印钞三个ETH:
1 | cast rpc anvil_setBalance 0x038405665520AC945D26eB7936ffe0115B2f2BBd 0x29a2241af62c0000 \ |
但是其实这里是无法完成的,因为0xf152950bed091c9854229d3eecb07fae4c84127704751a692c8409543dc02bd3这个哈希值不可能被还原为字符串。所以这个方法肯定是无法实现的。
这个合约的Internal Transaction普通模式下看不到这条交易,只有在Advanced Mode下才能看到,容易被误导只有一次交易,从而错误调用Try给合约转账导致大量损失!

利用BlockSec Phalcon分析
在start交易中,可以看到,调用栈中,并没有任何storage被修改,其实这里就可以看出这里的start函数只是一个幌子,是这个蜜罐的关键:https://app.blocksec.com/phalcon/explorer/tx/eth/0x2fffcd94fd19e2202223f1ce3f5e0ad44636a18e4d0e6ff960181e4982167e1e

在Internal Transaction中的New调用:https://app.blocksec.com/phalcon/explorer/tx/eth/0x62a1c781701c93b4d7a2aace3c42cf75ec540078ff6753824ab65a67a0806b12

可以看到这里是确实修改了question和responseHash
扩展分析
管理员抢跑攻击
由于存在New函数和Stop函数,管理员可随时调用,无条件修改答案哈希,或者随时撤走所有资金,而没有任何限制条件
管理员可监听mempool情况下高Gas随时抢跑退款离场
1 |
|
明显可疑点
- 管理员地址被刻意混淆
1 | constructor(bytes32[] memory admins) { |
构造函数接收 预计算的哈希值,而非地址。无法从合约代码或构造参数直接反推管理员地址,隐藏管理员身份
阻止用户保护性调用措施
1 | require(msg.sender == tx.origin); // 禁止合约调用 |
禁止用户通过保护合约调用,禁止使用闪电贷策略,而只能EOA调用。
强制要求 msg.sender == tx.origin,意味着用户必须使用 EOA直接调用,无法通过编写一个带有 require(balance > oldBalance) 的探测合约来进行安全包裹。
总结
经过以上分析,可以得出这是一个完完全全的有目的精心设计的蜜罐合约,一个猜谜蜜罐/钓鱼合约。
完整攻击场景还原
1 | ┌─────────────────────────────────────────────────────┐ |
风险等级评估
| 风险 | 等级 | 说明 |
|---|---|---|
| 蜜罐骗局设计 | 严重 | 合约本质为诈骗工具 |
| 管理员抢跑改答案 | 严重 | 用户永远无法真正获胜 |
| 管理员身份隐藏 | 高 | 阻碍追责 |
| tx.origin限制 | 高 | 阻止保护性措施 |
| 无事件日志 | 中 | 降低透明度 |
| 无时间锁/多签 | 中 | 管理员单点控制 |
蜜罐设计者如何让每个可疑特征都显得”合理”?
蜜罐设计者的伪装逻辑:
Q: 为什么Try()失败不revert?
A: “这是猜谜游戏,猜错要赔钱,revert了就没人投注了” ← 合理
Q: 为什么require(msg.sender == tx.origin)?
A: “防止闪电贷攻击和合约自动化套利” ← 看似合理
Q: 为什么admin用hash而不是address?
A: “增加安全性,防止管理员地址被针对攻击” ← 看似合理
Q: 为什么没有event?
A: “节省gas” ← 看似合理
→ 每个单独的设计都有”合理”的解释
→ 但组合在一起就形成了完美的蜜罐
→ 这正是高级蜜罐区别于低级蜜罐的地方