本文深入探讨了Solana程序安全中一些容易被忽视的漏洞,包括重复可变账户写入导致状态覆盖、Token-Agnostic接口无法保证Token-Agnostic转移、向量长度问题、Lamport转移Kill Switch、预创建ATA账户以及CPI签名者陷阱等。文章通过实例分析了这些漏洞的成因和潜在危害,并提供了相应的防御措施和解决方案,旨在提高Solana程序的安全性。
许多 Solana 程序安全指南已经被编写——这篇,这篇和这篇——但随着 Solana 的受欢迎程度不断攀升,一些类型的 bug 仍然鲜为人知,或者至少很少被提及。今天我们将深入探讨这些被忽视的问题。每个例子都来自多种来源的混合:个人经验、真实世界的 bug 报告,以及 Solana 安全审计员分享的见解。
在 Anchor 中,你在 Accounts
结构体中声明的每个可变账户都会被加载到内存中,并在指令结束时按照你声明的顺序写回。如果你不小心传递了两次相同的账户(例如,account_a
和 account_b
都指向相同的 pubkey),Anchor 会很乐意地给你两个独立的可变引用——你将执行两次写入,但只有第二次会生效,覆盖第一次。
考虑以下奖励金库的例子,管理员可以通过两种方式增加用户的奖励:提高常规奖励或增加奖金。
在处理程序端,我们传入两个金库账户来演示这个概念。因为这两个账户是相同的,所以 Anchor 将两个可变引用加载到内存中;但是当它将账户序列化回时,只有奖金被写入,而之前的奖励增加丢失了。
即使交易成功,我们最终也会在交易结束时得到状态的差异。
如果你希望你的程序同时支持经典的 SPL Token mints 和较新的 Token-2022 mints,第一步是泛型地接受 token 程序:
这只解决了一半的问题。如果我们然后用...支付费用(或任何其他费用)
每当货币 mint 是 Token-2022 mint 时,调用仍然会失败。为什么?因为 anchor_spl::token::transfer
默默地忽略了你提供的程序,并且总是为传统的 SPL token 程序构建指令。这里:
现实中的确切 Bug
在 Cantina 上的 Tensor NFT Marketplace 竞赛期间(问题 #175),这个疏忽破坏了版税支付:任何试图用 Token-2022 mint 支付创作者版税的销售都失败了,有效地 DoS 了市场,直到调用被替换为 anchor_spl::token_interface::transfer_checked
,它尊重你传递的任何 token 程序。
这个 bug 根本不是 Solana 特有的——它是一个 Rust 语法陷阱。这里是如何发生的:假设你想声明一个长度为 N 的向量:
但是如果你不小心这样写——类似于在某些语言中声明一个数组:
因为 items
只有 1 字节长,所以写入索引 1 和 2 会触发越界 panic。
想象一个系统的持续运行依赖于将 lamport 转移到用户提供的地址。如果该转移可以被阻止,整个系统可能会被 DoS。有三种主要方式可以阻止它:
即使在转账后,用户提供的地址也不是租金豁免的。
该地址是一个可执行程序。
该地址在保留列表中,并在运行时从可写降级为只读。
King-of-SOL 示例(直接取自 Asymmetric Research Blog)
任何人都可以通过出价至少是前一次出价的 2 倍成为国王。
老国王获得其出价的 95% 的补偿。
剩余的 5% 进入奖金池。
如果在位国王存活 10 天而没有被推翻,他们可以申领整个奖金池。
Solana 上的每个账户都必须支付租金才能存在,即使 data.len() == 0
。考虑以下场景:一个账户需要 1 SOL 才能获得租金豁免,仅仅是为了理解,但用户 Nirlin 提供了一个有 0 lamports 的空地址。当下一个投标人下注时,应该将 0.8 SOL 转移到该地址。
转账后,该地址仍将低于租金豁免的最低限额,因此运行时的检查将拒绝 lamport 移动,DoS 程序并阻止进一步的投标。
如果提供的 new_king
是一个可执行程序的地址,则 lamport 转移仍然会失败;这是在 set_lamports 函数中在运行时强制执行的。
在 Solana 上,在交易中标记为可写的账户可以被静默地降级为只读。这发生在消息清理期间——甚至在你程序执行之前。
Solana 运行时维护一个 保留账户列表,其中包括具有特殊语义的地址——例如内置程序、预编译和系统变量。
在这些被激活之后,它们不能被写入锁定。看这里
如果我们单独检查每个问题,会出现几种解决方法。对于第一个问题,你可以检查转账是否会使账户获得租金豁免,如果不会,则中止。对于第二个问题,Anchor 允许你验证提供的地址不是可执行程序。对于场景 3,你可以维护一个硬编码的保留地址列表——但该列表可能会更改,因此它是脆弱的。
一个更简洁的修复方法是将退款 lamports 保存在由你的程序拥有的 PDA 金库中,并让用户稍后提取资金——一个经典的 pull-vs-push 模式。
init
要在 Anchor 中为用户的 mint 创建一个关联的 token 账户 (ATA),请使用 init
约束,如以下示例所示:
乍一看,这似乎很好,但事实并非如此——使用它的指令很容易被 DoS。首先,让我们检查一下关联的 token 账户是如何创建的。在底层,Anchor 调用 create_associated_token_account
,它接受四个参数:
funding_address: &Pubkey.
wallet_address: &Pubkey.
token_mint_address: &Pubkey.
token_program_id: &Pubkey.
钱包地址不必是签名者——任何人都可以付费创建 ATA。假设有一个 $LATE token 的 mint。Nirlin(或任何其他人)可以无需许可地创建 Nirlin 的该 mint 的 ATA。这打开了一个攻击向量:Bob 可以抢先创建 Nirlin 的用户 ATA,当程序稍后尝试 init
它时,指令会失败。
Anchor 为关联的 token 账户提供了一个 init_if_needed
约束。如果 ATA 已经存在,Anchor 只是加载它;如果不存在,Anchor 会动态地创建它。使用 init_if_needed
而不是 init
可以防止上面描述的恶意预初始化 DoS。
在 Solana 上,在顶层指令中标记为签名者的任何账户都会为其后的每个跨程序调用 (CPI) 保留其签名者权限——不需要额外的签名。因此,任何被调用程序都可以完全像用户签署了其指令一样对待该账户。
Ethereum 的工作方式不同:在嵌套调用中,msg.sender
始终是直接调用者,因此这里不存在这种签名者转发的意外。
以下是恶意程序滥用转发的签名者的两种简单方法,以及你将需要的防御代码。
在 Solana 上,每个账户——包括用户钱包和 PDA——都由一个程序“拥有”。默认情况下,该所有者是系统程序 (11111111111111111111111111111111
),但可以使用系统 assign
指令更改所有权。重新分配后,只有新的所有者程序才能修改账户的数据或重新分配它,并且通过系统程序进行的标准 lamport 转移将会失败,因为源账户不再由系统拥有。
你的应用程序将签名者账户(钱包或 PDA)传递到不受信任的 CPI 中。
被调用者调用 system_program::assign
,将其账户的所有者设置为它自己。
从那时起:
用户的私钥无法再重新分配该账户,因为只有当前所有者程序才允许这样做。
从该账户进行的普通 lamport 转移会失败,因为该账户不再由系统拥有。
留在账户中或稍后发送到账户中的任何 lamports 实际上都会被冻结(或者可以通过攻击者程序在后续 CPI 中重定向)。
结果通常是永久的 DoS 或资金锁定,而不是立即的 drain,但影响同样严重。
在每次接收外部签名者的 CPI 之后,立即断言账户的所有者仍然是系统程序。
在 Ethereum 上,msg.value
明确说明了调用可能移动多少 ETH。Solana 是不同的:一旦你将一个账户作为签名者传递,CPI 链中的每个下游程序都可以花费该账户持有的每个 lamport。
你的协议 CPI 进入之前经过审计的 YieldFarm
程序。
你将用户的钱包作为签名者以及一些 SPL-token 账户转发。
几个月后,YieldFarm
升级权限被泄露。攻击者发布了一个新版本,该版本会消耗它接收到的任何签名者的 lamports。
用户调用你的协议 -> 你的协议 CPI 进入现在恶意的 YieldFarm
-> YieldFarm
转移出用户钱包中的每个 lamport。
在 CPI 之前 快照签名者的 lamport 余额,执行调用,然后断言调用之后 的余额没有下降(或者仅下降了你明确允许的金额,例如费用)。
Solana 上的安全性不是记忆一个不断增长的检查清单——而是让你的好奇心比攻击者领先一步。上面的每个边缘案例都在有人最终大喊“等等……什么?”之前破坏了真正的程序。
鸣谢: Asymmetric Research、OtterSec、Cantina、Sherlock 以及我的朋友 InfectedCrypto 发现 bug、运行公开竞赛并分享无情的边缘案例研究。
- 原文链接: substack.com/home/post/p...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!