我对ZK相关的内容一直比较感兴趣,所以之前也关注过一些ZK系的链,比如Starknet。它的抽象账户(AA)模式和钱包本身都挺有意思的。AA实现的效果是,用户若想在链上拥有一个账户,必须先部署一个账户合约。

这多少有点“反直觉”,就是你要部署新账户,好像就得先有个带Gas(以前是ETH,现在是STRK)的老账户给这个还不存在新账户冲点值。往上追溯,感觉就像“鸡生蛋,蛋生鸡”的问题。所以对于新用户来说,你得先从别处搞到STRK,比如通过跨链桥给你这个新地址充值,才能完成账户部署,无形中提高了使用门槛。

为了更友好地解决这个恼人的新手上车问题,Starknet上的主流钱包Braavos想了个办法,它内置一个Gasless功能:即,在手机钱包创建完成之时,也就是助记词刚备份页面后,App就自动帮你把账户部署上链了。

这个神奇的代付过程瞬间就吸引住我了。相比Paymaster的概念,这个功能更像一个纯粹的“Gas Funding”。仅凭直觉,这里有两个值得深究的潜在风险点:

  1. 这个过程有没有Rate Limit? 如果没有,我能不能写个脚本疯狂触发它,让它为我创建海量的账户,直接把它的Gas资金池给消耗光?
  2. 它怎么验证我提交的交易内容? Braavos的后端,用它自己的钱,去调用一个由我提供参数的交易。如果它验证不严,我给它任何一个交易它都傻傻地去签名执行,比如我构造一个“转账”的交易,那是不是就可以直接把它账户里的钱都转走了?

Play Integrity API绕过

本打算先抓包看看,搞清楚它这个代付的原理。但问题马上就来了。

我发现在Android手机上,在一个能抓包环境——比如装了Magisk,方便用Frida去解除SSL Pinning——Braavos App就不发送那个部署账户的网络请求。同一台手机,处于一个“干净”的、没法抓包的环境里,它又一切正常。

我一开始以为是 App 自己加了某种root检测,就试着找它相关的片段,但因为是React Native写的,逆向看它的钱包很麻烦。

看到一些疑似点但好像都对不上,一点点排除各种条件,最后发现只要手机的Bootloader解锁了,就不发包。甚至,只要把开发者选项里那个“Allow OEM Unlock”的开关打开,它都不发。

这看起来,感觉不像是App自己写的那种Root检测了。应该是一种更系统、更严格的检查。我让gpt帮我盲猜了一些可能的检测点,然后人肉hook发现com.google.android.play.core.integrity被触发到了。这才发现跑不通的根本原因是这个之前听过但从未正面交手过的Play Integrity API

简单来说,这是Google官方推出的一套检测机制,App可以调用它来获取token,但发包过程中,这套API会发送手机环境以及attestation签名信息给远端,以决定当前环境是否“安全”。我突然想起来Magisk的作者John Wu,很早之前就在Twitter上经常抱怨SafetyNet(Play Integrity API的前身),Google那边推一个小更新,好不容易研究出的绕过方法可能就废了,对抗过程属于纯体力活。尽管如此,但XDA社区总有人提出新的绕过方案。

既然很可能是Play Integrity API的问题,就先不去啃React Native。现在首要目标是,如何在一个Root环境下,骗过Play Integrity API的检测。

其实如果我们的测试机可以有EL3的权限,这种检测肯定能过,降维打击直接改用户态内容,EL1往下自身可以完全不动。只不过这样很多东西需要重写,所以还是尽可能找更通用的方法。

这个问题一直是个Android root爱好者的痛点,所以相关绕过方法还是总在更新。目前,测试机如果降级到Android 12,靠两个Magisk插件可以绕过Play Integrity:

Play Integrity Fix (PIF):

它的作用是伪装设备属性。当Play Integrity API检查设备状态(如Bootloader锁)时,PIF会拦截并返回一套“正常”手机的参数,从而骗过检测。

TrickyStore:

Play Integrity API检测最关键的一环是需要硬件可信区(Keystore)内的私钥进行签名。这类插件通过Hook较高层级的API调用,允许使用自定义的证书体系来通过验证。

折腾好这两个插件后,成功抓到了Braavos钱包发送的关键请求。

可控的class_hash

成功抓包后,能看到部署账户主要分为两步,先调用后端的/simulate接口进行模拟,再调用/execute接口真正上链执行。请求包如下:

POST /prod/gasless/tx/simulate HTTP/2
Host: geqr5qrwjh.execute-api.us-east-1.amazonaws.com
X-Firebase-Appcheck: <REDACTED_EXAMPLE_TOKEN>
Content-Type: text/plain;charset=UTF-8
...

{
    "network": "mainnet-alpha",
    "calls": [
        {
            "contractAddress": "0x03d94f65ebc7552eb517ddb374250a9525b605f25f4e41ded6e7d7381ff1c2e8",
            "entrypoint": "deploy_braavos_account",
            "calldata": [
                "0x4ee6df0656972b4e096902b735f02f706a7c2142f9547b2b81a39074d23ce41",
                "13",
                "0x03957f9f5a1cbfe918cedc2015c85200ca51a5f7506ecb6de98a5207b759bf8a", // account_class_hash
                "0x0", "0", "0", "0", "0", "0x0", "0x0", "0x0", "0x0",
                "0x534e5f4d41494e",
                "3233904491969167010184796238085025547722936772405769931232663921476088886149",
                "2165938814148688931756724393060713462260373918073253056937201152616284205373"
            ]
        }
    ],
    "account": "0x2e3e9a4a70bca7a997cb65fe30d9c04f49d6d69d3067d69516f9e98f9261ad1",
    "walletVersion": "4.9.2",
    "deviceType": "mobile"
}

请求头中的X-Firebase-Appcheck是Play Integrity API请求得到的token,其有效期约为1小时,期间可以随意调用simulate和execute接口,这意味着我们已经可以随意部署账户了,第一个关于速率限制的猜想基本成立。

我试着修改calldata中的合约地址、调用的函数名,发现后端有校验,这基本否定了我们第二个猜测——让他去执行任意转账是不可能的。

但我继续检查calldata里的参数,发现其中一个我标记为account_class_hash的参数(即0x03957f...),后端没有做任何校验,可以把它改成任意值。

这个class_hash是干嘛的呢?简单来说,starknet上是先有class后有合约,合约相当于class的实例。所以这里的class_hash定义了你将要部署的这个AA账户,底层用的是哪一套代码逻辑,正确值当然就是Braavos的账户class_hash。

在正常情况下,自己的账户,想改class_hash是可以的,这本来也是AA账户的特性,可以有允许多种账户类型。但现在,由于是别人(Braavos)帮你付钱去执行这个部署操作,而你又可以控制这个class_hash,这就出问题了。

通过分析Braavos的账户工厂合约(factory.cairo)和账户基础合约(braavos_base_account.cairo)的代码,可以看到被调用的函数依次是 deploy_braavos_account -> initializer_from_factory

在账户基础合约的初始化逻辑中,代码如下:

// 从传入的参数中直接读取 account_chash
let account_chash = (*deployment_params.at(0)).try_into().unwrap();
// 使用这个 class_hash 替换当前合约的实现
replace_class_syscall(account_chash).unwrap_syscall();

// 然后使用这个 class_hash 调用其初始化函数
let mut depl_cdata = array![stark_pub_key.pub_key];
depl_cdata.append_span(deployment_params);
library_call_syscall(
    class_hash: account_chash,
    function_selector: Consts::INITIALIZER_FROM_FACTORY_SELECTOR,
    calldata: depl_cdata.span(),
).unwrap_syscall();

由于这里的account_chash直接来源于deployment_params,而这个参数数组正是我们通过calldata从前端传入的!Braavos后端没有对这个值进行校验,所以攻击者可以提供一个自己部署的恶意代码的class_hash

library_call_syscall就类似于EVM的delegatecall,而这个class_hashinitializer_from_factory函数可以包含任意逻辑。当Braavos后端收到这个被篡改过的请求后,会让它的赞助者账户在链上发起这笔交易。结果就是,赞助者账户付费,为我提交的任意代码逻辑执行买单。

当然,走到这里,caller 的上下文已经变成了工厂合约,我不能再用这个身份去转移赞助者账户里的资产了。所以,这个漏洞最终表现为一种虽然可以“执行任意代码”,但最直接的危害是——耗尽赞助者的Gas资金。

漏洞的影响力:分析资金池

那它到底有多少Gas可以给我烧呢?我上链分析了一下。

我发现Braavos至少用了3个赞助者账户来并发处理这些请求(我猜他们后端可能是开了至少3个线程来做这件事):

  • 0x05e8fc9916168dca043f825b4024170826e529c56a81b9c7bdc7a6272c1d7a44
  • 0x07be43131dccfdd41ea1b06f3e13026e827653932de281675daea6c6f905b5bd
  • 0x0761a5d53b8133d70140845fbc522f63adf80f3b9ed979d2eb7f772f76c1b206

这3个账户里的STRK余额都不算多,每个账户余额通常保持在1000 STRK以下。

但真正的金矿在它们背后。这3个账户由同一个“资金补给账户”按需充值:

  • 0x040e32e176dca8f7fba4fab267763172d4530add0719b62ad77d96a2903030ad

这个补给账户里,躺着价值约51,000美元的各种代币(ETH, USDC等)。它会定期把这些币换成STRK,给那3个一线账户“发工资”。

这下,整个攻击的潜在影响就非常清晰了:

  1. 通过构造高计算量的交易,可以快速耗光3个赞助者账户里的所有STRK。
  2. 由于存在充值机制,攻击可以7x24小时进行。只要补给账户里有钱,攻击者就能把它变成一个持续烧钱的无底洞,慢慢耗干那个5万美金的资金池。
  3. Gas被烧光后,所有新用户都无法通过Gasless机制正常创建账户,Braavos的拉新流程会直接瘫痪。

后记:关于漏洞评级

这个漏洞报告我第一时间通过https://braavos.app/braavos-wallet-bug-bounty-program/提交给了Braavos。他们立刻确认并修复了。但在漏洞评级上,我们的看法出现了分歧。

我觉得,一个能造成直接、持续性经济损失,并且能导致平台核心功能(新用户引导)瘫痪的漏洞,至少应该是个高危(High)

但Braavos团队最终将它评为低危(Low)。他们的理由是:这没有影响到用户的资金,只是影响了Braavos自己的一个“可选的UX功能”,而且他们赞助者账户的余额是故意保持在低位的,所以经济损失可控。

我不是很认同这个判定。如果一个黑客利用这个漏洞,其攻击行为会非常隐蔽。Braavos那边可能只会看到Gas消耗异常加快,如果没第一时间定位到问题,可能只会被动地持续向资金池充值,导致损失不断扩大。虽然不能直接转移资金,但攻击者可以利用赞助者的Gas来执行自己的计算密集型任务,或进行其他形式的链上套利,这本身就是一种直接的经济损失。

可能这类“烧Gas”的漏洞比较少见,所以可参考的“判例”也比较少。界定它的严重性时候,尴尬的总是提交者。