发布于周一 19:423天前 背景 很多钱包被盗并不是私钥立刻泄露,而是用户签过一次高风险授权,后续被恶意合约或被控地址慢慢转走资产。社区里经常看到类似情况:用户只记得“点过一次 Mint / Claim / 空投页面”,链上看则是 approve、setApprovalForAll 或 Permit 签名被利用。 如果做钱包、机器人或内部资金管理,建议不要只依赖前端提示。更稳妥的做法是在本地加一层轻量风控:交易发出前解析 calldata,识别高风险授权、异常接收方、黑名单关联地址,并在签名前拦截或二次确认。 技术分析 以 EVM 链为例,最常见的高风险操作主要有几类: ERC-20 无限授权:approve(spender, uint256),当 amount 接近 2^256-1 时通常代表无限授权。 ERC-721 / ERC-1155 全量授权:setApprovalForAll(operator, true),一旦 operator 是恶意地址,NFT 可能被批量转走。 Permit 类签名:用户不发链上交易,但签名本身可以让攻击者代发授权交易,需要解析 EIP-712 typed data。 直接转账到高风险地址:比如已知钓鱼合约、混币入口、被盗资金归集地址、近期新创建且频繁收款的地址。 本地风控的关键不是“百分百判黑”,而是把明显危险的操作在签名前暴露出来。实际落地时可以将风险拆成几个维度: 函数选择器:通过 calldata 前 4 字节识别调用方法。 参数解析:解析 spender/operator、amount、approved 等关键字段。 地址画像:维护本地 denylist、近期高风险交互地址、内部白名单。 合约状态:检查目标地址是否为合约、合约创建时间、是否开源、是否代理合约。 行为规则:如“新地址 + 无限授权 + 非主流合约”直接高危。 操作步骤 收集交易对象:拿到待签交易的 to、data、value、chainId。 解析函数选择器:读取 calldata 前 4 字节,匹配常见 ABI。 提取关键参数:重点关注 spender、operator、amount、approved。 计算风险分:结合本地规则,比如无限授权 + spender 命中风险地址则高危。 输出可读提示:不要只显示“风险交易”,应明确说明“你正在授权某地址无限使用某代币”。 保留人工确认:高危交易建议默认拦截,必要时要求二次确认或硬件钱包确认。 代码示例 下面是一个简化版 Node.js 示例,用于解析 ERC-20 approve 和 ERC-721/1155 setApprovalForAll。实际生产环境建议接入完整 ABI、链上 RPC 校验和内部地址库。 import { Interface, getAddress, MaxUint256 } from "ethers"; const ERC20_ABI = [ "function approve(address spender,uint256 amount) returns (bool)" ]; const NFT_ABI = [ "function setApprovalForAll(address operator,bool approved)" ]; const erc20Iface = new Interface(ERC20_ABI); const nftIface = new Interface(NFT_ABI); // 示例:本地高风险地址库,实际应定期更新并记录来源 const denylist = new Set([ "0x1111111111111111111111111111111111111111".toLowerCase(), "0x2222222222222222222222222222222222222222".toLowerCase() ]); function isUnlimitedApproval(amount) { // 很多钱包会用 MaxUint256,也可以把大于余额若干倍的授权视为风险 return BigInt(amount.toString()) === BigInt(MaxUint256.toString()); } function normalizeAddress(addr) { try { return getAddress(addr).toLowerCase(); } catch { return null; } } function analyzeTx(tx) { const result = { level: "low", reasons: [], decoded: null }; if (!tx || !tx.data || tx.data === "0x") { return result; } // 1. 尝试解析 ERC-20 approve try { const parsed = erc20Iface.parseTransaction({ data: tx.data, value: tx.value || 0 }); if (parsed && parsed.name === "approve") { const spender = normalizeAddress(parsed.args.spender); const amount = parsed.args.amount; result.decoded = { type: "ERC20_APPROVE", token: tx.to, spender, amount: amount.toString() }; if (isUnlimitedApproval(amount)) { result.level = "medium"; result.reasons.push("检测到 ERC-20 无限授权"); } if (spender && denylist.has(spender)) { result.level = "high"; result.reasons.push("spender 命中本地高风险地址库"); } return result; } } catch (_) { // 不是 approve,继续尝试其他 ABI } // 2. 尝试解析 NFT setApprovalForAll try { const parsed = nftIface.parseTransaction({ data: tx.data, value: tx.value || 0 }); if (parsed && parsed.name === "setApprovalForAll") { const operator = normalizeAddress(parsed.args.operator); const approved = parsed.args.approved; result.decoded = { type: "NFT_SET_APPROVAL_FOR_ALL", collection: tx.to, operator, approved }; if (approved === true) { result.level = "medium"; result.reasons.push("检测到 NFT 全量授权"); } if (operator && denylist.has(operator)) { result.level = "high"; result.reasons.push("operator 命中本地高风险地址库"); } return result; } } catch (_) { // 未识别 } return result; } // 示例交易结构,实际可来自钱包插件、后端服务或交易构造模块 const tx = { chainId: 1, to: "0xTokenContractAddress000000000000000000000000", value: 0, data: "0x095ea7b30000000000000000000000001111111111111111111111111111111111111111ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }; console.log(analyzeTx(tx)); 规则设计建议 单条规则容易误报,建议用组合规则: 低风险:普通转账、授权金额较小、交互对象在内部白名单。 中风险:无限授权、NFT 全量授权、目标合约未验证、首次交互地址。 高风险:命中高风险地址库、钓鱼域名来源、授权对象与被盗资金归集地址有关联。 地址关联可以先做简单版本:记录资金流入流出的一跳关系。例如某 spender 曾经把资产转入已知盗币归集地址,即使 spender 本身没在黑名单里,也应至少标记为中高风险。后续再扩展多跳图谱、时间窗口、金额阈值。 注意事项 不要只看域名:很多钓鱼站会频繁换域名,链上 spender/operator 更稳定。 不要只靠黑名单:新地址没有历史记录,仍可能通过“首次交互 + 无限授权”识别风险。 Permit 必须单独处理:它不一定有链上 tx data,需要解析用户签名的 typed data。 代理合约要追实现:如果目标是 proxy,应读取 implementation slot,否则只看 proxy 源码意义有限。 提示要具体:用户真正需要知道的是“谁可以动我的什么资产、额度是多少”。 总结 钱包风控不一定一开始就做成复杂的链上情报系统。先从本地 calldata 解析、授权识别、风险地址库、组合规则做起,就能挡住相当一部分常见盗币路径。重点是把风险前置到签名前,而不是等资产被转走后再追踪。 实际落地时建议先覆盖 approve、setApprovalForAll、Permit、常见 Router 交互,再逐步接入链上地址画像和资金流关联。规则简单但稳定,比只依赖前端展示更可靠。
9小时前9小时 楼主 本地规则做钱包风控是可行的,关键是把规则拆成“交易前静态检查”和“广播前模拟检查”,不要只看收款地址。 可以重点加几类规则: 1. ERC-20 授权风险 很多被盗不是直接转账,而是 `approve`/`increaseAllowance` 给了异常 spender。建议本地解析 calldata,遇到以下情况直接拦截或二次确认: - `approve(spender, uint256)` 且 amount 接近 `uint256.max` - spender 是新创建合约、无源码、短期内被多个受害地址授权 - spender 不是常见路由/协议合约,但请求无限授权 - token 本身是假币或带钓鱼符号,比如名称伪装成 USDT/USDC 函数选择器可以先覆盖这些: approve(address,uint256) 0x095ea7b3 increaseAllowance(address,uint256) 0x39509351 permit(...) 需按 EIP-2612 / Permit2 分开解析 如果是 ethers v6,可以本地这样解析: import { Interface, MaxUint256 } from "ethers"; const erc20 = new Interface([ "function approve(address spender,uint256 amount)", "function increaseAllowance(address spender,uint256 addedValue)" ]); function checkERC20Approval(tx) { if (!tx.data || tx.data.length < 10) return null; try { const parsed = erc20.parseTransaction({ data: tx.data, value: tx.value || 0 }); if (!parsed) return null; if (parsed.name === "approve") { const spender = parsed.args.spender; const amount = parsed.args.amount; if (amount > MaxUint256 / 2n) { return { level: "high", reason: `无限授权风险,spender=${spender}` }; } } if (parsed.name === "increaseAllowance") { return { level: "medium", reason: `增加授权额度,spender=${parsed.args.spender}` }; } } catch (_) {} return null; } 2. Permit / Permit2 要单独处理 现在很多钓鱼不走链上 `approve`,而是诱导用户签名 `permit` 或 Permit2 的 `PermitSingle/PermitBatch`。本地风控如果只看交易,会漏掉一大块。 建议对签名请求做 EIP-712 域检查: - `domain.verifyingContract` 是否为官方 Permit2:`0x000000000022D473030F116dDEE9F6B43aC78BA3` - `spender` 是否在本地白名单 - `deadline` 是否异常长 - `amount` 是否接近最大值 - `chainId` 是否和当前网络一致 特别是 Permit2,一旦给了恶意 spender,后面不需要用户再签 ERC-20 approve。 3. 地址关联不要只靠“黑名单命中” 本地规则可以做轻量级图关系,不一定要跑完整链上图数据库。比较实用的是维护几类边: - 资金来源边:A 给 B 转过 ETH/原生币作为 gas - 交互边:A 调用过 B 合约 - 授权边:A approve 给 B - 共同归集边:多个地址把资产转到同一个地址 - 创建者边:EOA 创建了某个合约 比如可疑地址不直接命中黑名单,但它的 gas 来源来自已知钓鱼地址,或者它创建的合约被大量受害者授权,就可以提升风险分。 规则示例: { "rules": [ { "name": "spender_related_to_bad_funder", "type": "approval", "condition": { "spender.gas_funder_in": "local_bad_addresses", "max_depth": 1 }, "score": 40 }, { "name": "new_contract_unlimited_approval", "type": "approval", "condition": { "spender.is_contract": true, "spender.contract_age_blocks_lt": 7200, "amount_is_unlimited": true }, "score": 50 }, { "name": "victim_cluster_spender", "type": "approval", "condition": { "spender.unique_approvers_24h_gt": 20, "spender.source_verified": false }, "score": 60 } ], "thresholds": { "warn": 40, "block": 80 } } 4. 广播前最好加一次模拟 本地规则只能看意图,模拟可以看结果。建议接入本地节点或 RPC 的 `eth_call`,在 pending block 上模拟交易,观察: - 用户地址 ERC-20 balance 是否减少 - NFT owner 是否变化 - allowance 是否新增或扩大 - 是否触发不常见合约调用链 - 是否通过 multicall / aggregator 包了一层真实调用 如果能用 debug 接口,效果更好: debug_traceCall trace_call eth_call 尤其要注意 `multicall`、`execute`、`swap`、`claim` 这类表面函数,真实风险经常藏在内部 call 里。 5. 本地缓存几个字段就够用 不一定一开始就做很重的链上索引。可以先为地址缓存这些信息: { "address": "0x...", "is_contract": true, "first_seen_block": 19300000, "creator": "0x...", "source_verified": false, "labels": ["unverified_contract"], "funders": ["0x..."], "recent_approver_count": 12, "bad_distance": 1 } 风险分可以按交易实时计算,缓存过期时间按字段区分:合约创建者基本永久,recent approver 这类用 1h/24h 窗口即可。 个人建议拦截优先级可以这样排: - 高危:无限授权给未知 spender、Permit2 授权给未知 spender、收款地址命中本地黑名单或一跳关联 - 中危:新合约、未验证合约、短期大量授权、gas 来源异常 - 低危:首次交互、ENS/符号伪装、金额超过用户历史分位数 最后一点,本地规则一定要保留“解释原因”。不要只弹一个风险分,最好明确告诉用户:这笔交易是在给哪个 spender 授权、额度是多少、这个 spender 和哪个可疑地址有关。这样误报时也方便用户判断。
6小时前6小时 补一块比较容易漏的:`permit`/Permit2 的风险不一定体现在当前交易 `to` 地址上,很多钓鱼页面是让用户先签名,后面由攻击者代付 gas 调 `permitTransferFrom` 或 `transferFrom`。所以本地风控最好把“签名内容解析”也纳入,而不是只拦截链上 tx。 可以加三类检查: 1. EIP-712 签名域检查 重点看 `domain.verifyingContract`、`chainId`、`name`,不要只展示“你正在签名”。例如 Permit2 正常一般是固定合约地址,不同链不一样;如果 verifyingContract 是陌生合约,但 message 里又出现 token/spender/amount,就要提高风险级别。 ethers v6 里可以这样先把 typed data 打出来做规则判断: import { TypedDataEncoder, getAddress, MaxUint256 } from "ethers"; function checkTypedData(domain, types, value, allowList) { const verifying = domain.verifyingContract ? getAddress(domain.verifyingContract) : null; const chainId = Number(domain.chainId || 0); const warnings = []; if (!verifying) { warnings.push("EIP-712 缺少 verifyingContract"); } else if (!allowList.permitContracts?.[chainId]?.has(verifying)) { warnings.push(`非常见 EIP-712 验证合约: ${verifying}`); } const primaryType = TypedDataEncoder.getPrimaryType(types); // 粗略递归找 amount / value / nonce / deadline / spender const found = {}; function walk(obj) { if (!obj || typeof obj !== "object") return; for (const [k, v] of Object.entries(obj)) { const key = k.toLowerCase(); if (["spender", "operator", "to"].includes(key)) found[key] = v; if (["amount", "value", "allowed"].includes(key)) found[key] = v; if (["deadline", "expiration", "sigdeadline"].includes(key)) found[key] = v; if (typeof v === "object") walk(v); } } walk(value); if (found.spender && !allowList.spenders?.[chainId]?.has(getAddress(found.spender))) { warnings.push(`签名授权给非常见 spender: ${found.spender}`); } if (found.amount) { const amount = BigInt(found.amount.toString()); if (amount > (MaxUint256 / 2n)) { warnings.push("签名中包含接近无限额度授权"); } } if (found.deadline) { const deadline = BigInt(found.deadline.toString()); const now = BigInt(Math.floor(Date.now() / 1000)); if (deadline === 0n || deadline > now + 86400n * 30n) { warnings.push("授权 deadline 过长或无过期限制"); } } return { primaryType, verifyingContract: verifying, warnings }; } 2. 对 Permit2 单独建规则 Permit2 常见入口不只是 `permit`,还要关注这些 selector: permit(address,((address,uint160,uint48,uint48),address,uint256),bytes) permitBatch(address,((address,uint160,uint48,uint48)[],address,uint256),bytes) permitTransferFrom(((address,uint256),uint256,uint256),((address,uint256)),address,bytes) permitWitnessTransferFrom(...) lockdown(...) 其中 `uint160 amount`、`uint48 expiration`、`sigDeadline` 很关键。很多 UI 只展示 token,不展示 spender 或 expiration,本地规则里建议强制显示: - token - spender - amount - expiration - sigDeadline - nonce - Permit2 合约地址 - 当前 chainId 如果 `spender` 不是当前交互 dApp 的已知合约,却要求大额度或长时间有效,建议直接红色提示。 3. 做一次 allowance 状态对比 广播前模拟检查时,不只是看本笔交易是否成功,也要看交易后用户地址对外暴露了哪些 allowance。可以用 `eth_call` 在 pending block 或 fork 环境模拟后,再查询关键 token 的 `allowance(owner, spender)`。 最小化可以先做交易前后对比: const erc20Abi = [ "function allowance(address owner, address spender) view returns (uint256)", "function symbol() view returns (string)", "function decimals() view returns (uint8)" ]; async function getAllowance(provider, token, owner, spender) { const c = new ethers.Contract(token, erc20Abi, provider); return await c.allowance(owner, spender); } // 交易前记录 const before = await getAllowance(provider, token, owner, spender); // 如果本地能解析出 approve/permit 的目标 spender,广播前提示变化 const afterExpected = parsedAmount; if (afterExpected > before && afterExpected > riskThreshold) { console.warn("本次操作将扩大授权额度", { token, spender, before: before.toString(), after: afterExpected.toString() }); } 另外建议本地缓存一份“常见协议合约白名单”时不要只按地址,还要绑定: - chainId - contract address - expected bytecode hash - 首次加入时间 - 来源说明 单纯地址白名单在多链环境容易误判,尤其是同地址不同链、代理合约升级、钓鱼合约仿冒名称这几类场景。实际落地里,规则优先级可以设成:签名解析 > calldata 解析 > 合约画像 > 地址关联,这样误报会低一些。
创建帐户或登录后发表意见