本文探讨了区块链应用中链下 TypeScript 代码常见的严重安全漏洞,例如桥接中继器、守门人、预言机后端等。主要介绍了输入验证不当(如 BigInt 解析错误)、宽松的类型强制转换、原型污染以及 JSON 解析中的数字精度丢失等问题,并提供了相应的代码示例。

链下组件可能会引入严重的安全性和可靠性风险,但其审查通常是私密的,因为它们包含专有业务逻辑和操作细节。这导致团队可以学习并避免重复错误的公开示例较少。
在本文中,我们分享了我们对桥接中继器、keeper、预言机后端和交易签名服务的审查中获得的实用经验(重点关注 TypeScript 特有的陷阱),以便更早地发现并预防常见问题。
当我们考虑区块链安全时,智能合约往往主导了讨论。
然而,越来越多的区块链应用程序逻辑存在于链下:在 TypeScript 后端、API 服务器和订单匹配引擎中,它们协调着最终在链上发生的事情。这些组件处理从输入解析和签名验证到订单匹配和交易提交的所有事务,并且它们对于整个系统的安全通常同样关键。
ChainSecurity 一直在进行链下安全审计,同时进行传统的智能合约审查,其发现令人大开眼界。
在这篇文章中,我们分享了我们在基于 TypeScript 的区块链基础设施中遇到的一些最有趣的漏洞模式,从微妙的类型强制转换问题到跨越链上/链下边界的竞争条件。
输入验证仍然是链下代码中最具挑战性的领域之一。即使在成熟的 TypeScript 代码库中,用户提供的字符串解析为数字类型的方式也会导致细微的问题。
在我们的一次审计中,我们发现了一个错误,其中字符串值在没有足够的格式验证的情况下被解析为 BigInt,从而允许十六进制输入未经检查地通过。由于 BigInt 默默地接受十六进制字符串,解析后的值与最终存储在数据库中的值不一致,这使得用户可以绕过最低价格限制。
像这样的案例表明,输入验证不仅仅是拒绝明显格式错误的数据:它需要彻底了解堆栈的每一层如何解释其接收到的值,以及这些解释之间的差异如何被利用。
function parseQuantity(quantity: string, decimals: number): bigint {
const parts = quantity.split(".");
if (parts.length === 1) {
parts.push("".padEnd(decimals, "0"));
}
// 没有格式验证 — BigInt 接受像 "0x01" 这样的十六进制字符串
return BigInt(parts.join(""));
}
// 预期:parseQuantity("1", 3) → 1000n (即 1.000)
// 攻击:parseQuantity("0x01", 3) → BigInt("0x01000") → 4096n (即 4.096)
// 验证检查看到 4.096,但数据库将原始 "0x01" 存储为 1.000
JavaScript 的类型强制转换规则是出了名的宽松,尽管 TypeScript 增加了一层静态安全性,但它无法捕获所有情况,尤其是在外部输入进入应用程序的系统边界。
API 有效载荷、查询参数和 WebSocket 消息以无类型数据形式到达,如果没有严格的运行时验证,值可能会以意想不到的形式通过。例如,一个权限检查使用 == 而不是 === 比较角色值,可能会无意中将"0"视为假值并授予不应有的访问权限。同样,接收到字符串 "0" 而不是数字 0 的余额检查可能会通过真值检查,因为在 JavaScript 中非空字符串是真值。
这些错误很容易引入,并且难以在测试中捕获,因为它们只在开发人员很少预料到的特定输入形状下才会显现。
// Express 提款路由处理程序
app.post("/withdraw", async (req, res) => {
const { amount, token } = req.body; // amount 从 JSON 中以字符串形式到达
// 错误:真值检查 — 字符串 "0" 是真值,因此此检查通过
if (amount) {
await processWithdrawal(token, amount);
}
});
TypeScript 应用程序经常将用户提供的 JSON 对象合并到内部配置或状态中:无论是通过 Object.assign()、展开运算符还是深度合并工具。
如果这些输入未经清理,攻击者可以注入属性来修改内置对象的原型,从而有效地在全局层面篡改应用程序行为。
在区块链环境中,这可能尤其危险:被污染的原型可能会改变对象在签名前的序列化方式,更改交易构造中使用的默认值,或者绕过依赖属性查找的验证逻辑。
由于原型污染会影响所有共享被污染原型的对象,单个恶意请求可能会对整个应用程序产生深远且难以调试的后果。
function deepMerge(target: any, source: any): any {
for (const key in source) {
if (typeof source[key] === "object" && source[key] !== null) {
target[key] = deepMerge(target[key] || {}, source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
// 攻击者将此作为 JSON 有效载荷发送:
const maliciousInput = JSON.parse(
'{"_proto__": {"isAdmin": true, "defaultGasLimit": "100"}}'
);
const config = {};
deepMerge(config, maliciousInput);
// 现在应用程序中的每个对象都受到影响:
const user = {};
console.log((user as any).isAdmin); // true
console.log((user as any).defaultGasLimit); // "100"
一个更微妙的问题源于 JavaScript 在 JSON 反序列化过程中如何处理大数字。
JSON.parse() 将所有数值转换为 IEEE 754 浮点数,这会悄无声息地丢失超出 2^53 的整数的精度。在区块链应用程序中,这是一个真正的问题:具有 18 位小数的代币金额、成熟链上的区块号以及原始交易值都可能超出此阈值。
如果后端使用 JSON.parse 解析节点的 JSON-RPC 响应,可能会导致代币金额相差几个单位:这种差异可能在未被注意的情况下发生,直到它导致结算失败,或者更糟的是,导致链下系统认为的和链上强制执行的之间出现可利用的不一致。
像 json-bigint 这样的库或自定义 reviver 函数的存在是为了解决这个问题,但它们很容易被忽视,尤其是在开发和测试期间遇到的较小值时,标准的 JSON.parse() 也能正常工作。
// 具有 18 位小数的代币余额:1000000000000000001 (略高于 1 ETH)
const jsonResponse = '{"balance": 1000000000000000001}';
const parsed = JSON.parse(jsonResponse);
console.log(parsed.balance); // 1000000000000000000 — 最后一位数字悄无声息地丢失了!
console.log(parsed.balance === 1000000000000000000); // true — 值错误,没有报错
虽然我们链下工作的成果大多是私密的,但它们往往会给单个项目之外带来积极影响。在链下审查期间,我们有时会在广泛使用的库中发现影响更广泛生态系统的错误。
例如,我们发现 viem's verifyTypedData() 接受任意长度的签名,导致链下和链上签名验证之间不匹配。依赖此函数的链下应用程序可能会接受伪造的签名,而这些签名随后会在链上失败。报告这些上游优势惠及所有人。
- 原文链接: chainsecurity.com/blog/c...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!