跳到主要内容

DApp 安全

DApp 前端安全的独特性

传统 Web 应用的安全事故(数据泄露、XSS 等)通常可以通过回滚、封号、数据恢复等手段补救。但 DApp 前端安全有本质不同:

维度传统 WebDApp
操作可逆性服务端可回滚、可补偿链上交易不可逆,资产一旦转出无法追回
资产暴露度资产在服务端,前端不直接接触钱包私钥/资产直接暴露于前端环境
攻击收益窃取数据、劫持账号直接盗取加密资产,变现即时
用户认知用户熟悉传统交互用户对签名、授权的含义缺乏理解
信任模型信任服务端信任用户自己的判断(自托管钱包)
核心认知

DApp 前端的每一次签名请求,本质上都是在请求用户授权一笔可能无法撤销的资产操作。前端开发者有责任让用户充分理解自己在做什么。

面试速答版

DApp 前端安全的特殊性? 和传统 Web 完全不一样:

  • 链上交易不可逆——资产被转走没法回滚、没法封号补偿。
  • 私钥/资产直接暴露在前端环境里,任意签名都可能被利用。
  • 用户对签名/授权的含义往往不理解,前端有义务把风险讲清楚。

最常见的几个攻击面?

  • 盲签:钱包只显示一串 hash 用户随手签——必须用 EIP-712 结构化签名让钱包能展示可读字段。
  • Approve 钓鱼:诱导用户对 MaxUint256 无限授权,然后 spender 把所有 Token 抽走。防御:默认按需授权,提供撤销入口(Revoke.cash)。
  • Permit 钓鱼:EIP-2612 链下签名后能直接被调,看起来「只是签名」但等价交易。
  • eth_sign:能签任意 hash 等于全权委托——绝对不要让用户签 eth_sign
  • 前端被劫持:CDN 投毒、依赖供应链攻击导致 RPC 地址被改。SRI、CSP、依赖锁版本是基础防护。

怎么提升用户安全感?

  • 调用前用 eth_simulateV1 / Tenderly Simulation 预演交易,告诉用户「这笔会让你失去 X、得到 Y」。
  • 显示授权金额而非 MaxUint256,并显示授权对象的合约名/审计状态。
  • 大额操作走二次确认 / 硬件钱包提示。

签名风险

盲签(Blind Signing)

盲签指用户在不理解签名内容的情况下确认签名。早期钱包显示的签名信息往往是一串十六进制哈希,用户根本无法判断自己在签署什么。

// 用户在钱包中看到的内容
签名请求:
0x7f9fade1c0d57a7af66ab4ead79fade1c0d57a7af66ab4ead7c2c2eb7b11a91385

// 用户的反应:这是什么?算了,点确认吧...
盲签的危害

攻击者可以构造恶意的签名请求,让用户在不知情的情况下签署:

  • 无限授权(Approve)
  • 资产转移
  • NFT 上架出售(以极低价格)
  • Permit 授权(离线签名即可转移代币)

EIP-712 结构化签名

EIP-712 是解决盲签问题的关键标准,它定义了结构化的、人类可读的签名格式:

EIP-712 签名示例
const typedData = {
types: {
EIP712Domain: [
{ name: 'name', type: 'string' },
{ name: 'version', type: 'string' },
{ name: 'chainId', type: 'uint256' },
{ name: 'verifyingContract', type: 'address' },
],
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
// domain 明确了签名的目标合约和链
domain: {
name: 'USD Coin',
version: '2',
chainId: 1,
verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
},
primaryType: 'Permit',
message: {
owner: '0xUserAddress...',
spender: '0xSpenderAddress...',
value: '1000000', // 1 USDC
nonce: 0,
deadline: 1680000000,
},
};

钱包会以结构化方式展示给用户,用户能清楚看到「谁」在「哪条链」的「哪个合约」上「授权多少」给「谁」。

Permit 钓鱼(ERC-2612)

ERC-2612 Permit 允许用户通过离线签名来授权代币,无需发送链上交易。这本是为了节省 Gas 的优秀设计,但被攻击者恶意利用:

Permit 钓鱼的隐蔽性

Permit 签名是离线操作,不会产生链上交易,因此:

  • 用户在区块浏览器上看不到任何授权记录
  • revoke.cash 等工具无法检测到 Permit 授权
  • 攻击者可以选择最佳时机提交签名并转走资产

eth_sign 的危险性

eth_sign 允许对任意哈希进行签名,是最危险的签名方法:

三种签名方法的安全性对比
// 1. eth_sign - 极度危险,可签任意哈希
await provider.send('eth_sign', [address, arbitraryHash]);
// 攻击者可以构造任何交易的哈希让用户签名

// 2. personal_sign - 较安全,有前缀防止冒充交易
await provider.send('personal_sign', [message, address]);
// 自动添加 "\x19Ethereum Signed Message:\n" 前缀

// 3. eth_signTypedData_v4 - 最安全,结构化 + 可读
await provider.send('eth_signTypedData_v4', [address, typedData]);
// EIP-712 结构化签名,钱包可解析展示
签名方法安全性可读性建议
eth_sign极低不可读禁用,MetaMask 已默认禁用
personal_sign中等纯文本仅用于消息验证(如登录)
eth_signTypedData_v4结构化可读推荐,用于所有链上操作相关签名

Approve 授权风险

无限授权的危害

ERC-20 的 approve 机制要求用户先授权再转账。为了减少 Gas 消耗和用户操作次数,许多 DApp 默认请求无限授权type(uint256).max):

无限授权 vs 精确授权
import { parseUnits, MaxUint256 } from 'ethers';

// 危险:无限授权 - 合约可以转走用户所有代币
await token.approve(spenderAddress, MaxUint256);

// 安全:精确授权 - 只授权本次交易所需的数量
await token.approve(spenderAddress, parseUnits('100', 18));
无限授权的风险

即使 DApp 本身是安全的,无限授权也意味着:

  • 合约被攻击后,攻击者可以转走所有已授权的代币
  • 合约升级引入恶意逻辑时,用户资产直接暴露
  • 授权一旦给出就永久有效,除非用户主动撤销

恶意合约 transferFrom

攻击流程:

授权管理与最佳实践

检查和撤销授权的工具

前端最佳实践

安全的授权实现
import { parseUnits } from 'ethers';

async function safeApprove(
token: Contract,
spender: string,
amount: bigint
) {
// 1. 检查当前授权额度
const currentAllowance = await token.allowance(userAddress, spender);

// 2. 如果当前额度足够,无需再次授权
if (currentAllowance >= amount) return;

// 3. 精确授权(只授权需要的数量)
const tx = await token.approve(spender, amount);
await tx.wait();
}

// 使用 Permit2(Uniswap 推出的统一授权管理)
// 用户只需授权一次给 Permit2 合约,后续通过签名控制每次授权
async function usePermit2(token: Contract, permit2Address: string) {
// 只需对 Permit2 合约做一次授权
await token.approve(permit2Address, MaxUint256);
// 后续每次操作通过 Permit2 签名,可指定:
// - 精确金额
// - 过期时间
// - 目标合约
}
Permit2 的优势

Uniswap Permit2 提供了一个统一的授权管理层:

  • 用户只需对 Permit2 合约授权一次
  • 每次具体操作通过 EIP-712 签名控制,可设置金额过期时间
  • 支持批量授权和批量撤销
  • 大幅减少了无限授权的风险

钓鱼攻击防护

假网站攻击

攻击者创建与真实 DApp 几乎一模一样的假网站,诱导用户连接钱包并签署恶意交易:

攻击手段示例防护方式
相似域名uniswap.orgun1swap.org检查域名拼写,使用书签访问
Unicode 欺骗使用 Cyrillic 字符替换拉丁字符浏览器地址栏仔细检查
Google Ads 钓鱼搜索结果顶部的虚假广告不点击广告链接
DNS 劫持正确域名解析到恶意服务器检查 SSL 证书,使用安全 DNS

恶意 Token Airdrop

攻击者向大量地址空投假代币,代币合约的 transfer 函数中嵌入恶意逻辑:

恶意 Token 示例(Solidity 伪代码)
// 假代币合约的 transfer 函数
function transfer(address to, uint256 amount) public {
// 看似正常的转账...
// 但实际会引导用户到钓鱼网站
// 或者 require 中消耗大量 Gas
// 或者触发一个授权操作
}
防护建议
  • 永远不要与来历不明的空投代币交互
  • 不要尝试在 DEX 上出售来路不明的代币
  • 在区块浏览器上验证代币合约是否已验证(Verified)

地址投毒(Address Poisoning)

攻击者监控链上交易,生成与用户常用地址首尾几位相同的地址,然后向用户发送小额转账。用户从交易历史中复制地址时,可能误选攻击者的地址:

用户的真实地址:   0x1234...abcd
攻击者生成的地址: 0x1234...abcd (首尾相同,中间不同)

用户查看交易历史时看到两笔转入记录,
复制了攻击者的地址进行大额转账...

防护措施:始终从地址簿或合约中获取地址,永远不要从交易历史中复制地址。前端应完整显示地址或提供地址簿功能。

社工攻击

  • 冒充官方客服(Discord、Telegram 私信)
  • 伪造 "紧急安全升级" 通知
  • 虚假治理提案投票页面

前端安全措施

交易模拟

在用户确认交易前,通过模拟执行预判交易结果:

交易模拟实现
import { JsonRpcProvider } from 'ethers';

async function simulateTransaction(tx: TransactionRequest) {
const provider = new JsonRpcProvider(rpcUrl);

try {
// 使用 eth_call 模拟交易执行
const result = await provider.call(tx);

// 解析返回值,展示给用户
return {
success: true,
result,
// 展示:预计资产变动、Gas 消耗等
};
} catch (error) {
// 模拟失败说明交易会 revert
return {
success: false,
reason: '交易模拟失败,执行可能会失败',
};
}
}
专业模拟工具
  • Tenderly - 提供详细的交易模拟和状态变更可视化
  • Blowfish - 专注于交易安全扫描和风险评估
  • Pocket Universe - 浏览器插件,自动模拟并提示风险

域名白名单与 EIP-712 Domain 校验

前端 domain 校验
// 验证 EIP-712 签名请求的 domain 是否合法
function validateEIP712Domain(domain: {
name: string;
verifyingContract: string;
chainId: number;
}) {
// 白名单:已知安全的合约地址
const TRUSTED_CONTRACTS: Record<string, string> = {
'Uniswap V3 Router': '0xE592427A0AEce92De3Edee1F18E0157C05861564',
'USD Coin': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
};

// 检查合约地址是否在白名单中
const isTrusted = Object.values(TRUSTED_CONTRACTS).includes(
domain.verifyingContract.toLowerCase()
);

// 检查 chainId 是否匹配当前网络
const isCorrectChain = domain.chainId === expectedChainId;

return { isTrusted, isCorrectChain };
}

交易内容透明展示

前端有责任将用户即将签署的交易内容清晰、准确地展示

交易解析与展示
function renderTransactionSummary(tx: TransactionRequest) {
// 解析 calldata,展示人类可读的操作描述
const decoded = decodeFunctionData(tx.data);

return {
action: 'Swap', // 操作类型
from: '100 USDC', // 输入
to: '~0.05 ETH', // 预期输出
minReceived: '0.048 ETH', // 最小接收量
recipient: shortenAddress(tx.to), // 接收地址
estimatedGas: '150,000', // Gas 估算
warnings: checkForRisks(decoded), // 风险提示
};
}

CSP 与 XSS 防护

DApp 前端同样需要防范传统 Web 安全攻击,尤其是 XSS,因为一旦注入恶意脚本可以直接调用钱包 API:

CSP 配置示例(Next.js)
// next.config.js
const securityHeaders = [
{
key: 'Content-Security-Policy',
value: [
"default-src 'self'",
"script-src 'self'", // 禁止内联脚本和外部脚本
"connect-src 'self' https://rpc.ankr.com", // 限制网络请求目标
"frame-ancestors 'none'", // 防止 clickjacking
].join('; '),
},
{
key: 'X-Frame-Options',
value: 'DENY',
},
];
私钥/助记词安全铁律
  • 永远不要在前端代码中处理、存储或传输私钥/助记词
  • 永远不要要求用户在网页中输入助记词(这是钱包的职责)
  • 使用 window.ethereum 或 WalletConnect 等标准接口与钱包通信
  • 签名和交易发送必须通过钱包完成,前端只负责构造和展示

合约安全基础(前端视角)

前端开发者虽然不直接编写智能合约,但需要了解常见合约攻击,以便在前端层面提供防护:

攻击类型原理前端可做的防护
重入攻击合约在状态更新前回调外部合约交易模拟检测异常状态变更
闪电贷攻击利用无需抵押的贷款操纵价格显示价格偏差警告,比对多个数据源
三明治攻击MEV 机器人夹击用户交易设置合理的滑点保护,使用 MEV 保护 RPC
预言机操纵操纵链上价格数据源展示多源价格对比,标记异常偏差
滑点保护与 MEV 防护
// 设置滑点保护,防止三明治攻击
const slippageTolerance = 0.5; // 0.5%
const minAmountOut = expectedOutput * (1 - slippageTolerance / 100);

// 使用 Flashbots Protect RPC 防止 MEV 攻击
const FLASHBOTS_RPC = 'https://rpc.flashbots.net';
const provider = new JsonRpcProvider(FLASHBOTS_RPC);

安全工具与资源

交易安全工具

工具类型功能
Pocket Universe浏览器插件交易模拟、风险提示、钓鱼检测
BlowfishAPI / 插件交易扫描、资产变动预览
Wallet Guard浏览器插件钓鱼网站检测、交易安全分析
Tenderly开发工具交易模拟、调试、监控
revoke.cashWeb 工具授权查看与撤销
Flashbots ProtectRPC 节点MEV 保护、隐私交易

常见面试问题

Q1: 什么是盲签?为什么它是 DApp 安全的核心问题?

答案

盲签是指用户在不理解签名内容的情况下确认签名操作。在早期钱包中,签名请求通常只显示一串十六进制哈希,用户无法判断自己在授权什么操作。

盲签是 DApp 安全的核心问题,因为:

  1. 不可逆性:链上操作一旦执行无法撤回
  2. 资产直接暴露:签名可能直接导致资产被转移
  3. 用户认知不足:大多数用户不具备解读原始签名数据的能力

EIP-712 通过结构化、人类可读的签名格式来缓解盲签问题,钱包可以清晰展示签名的目标合约、链 ID、操作类型和具体参数。

Q2: eth_signpersonal_signeth_signTypedData_v4 有什么区别?

答案

  • eth_sign:对任意哈希签名,攻击者可构造任何交易哈希让用户签名,极度危险。MetaMask 已默认禁用
  • personal_sign:签名前自动添加 "\x19Ethereum Signed Message:\n" 前缀,防止签名被冒充为交易。适用于登录验证等场景
  • eth_signTypedData_v4:EIP-712 结构化签名,包含 domain 信息和类型化数据,钱包可解析并展示人类可读内容。是最安全的签名方式

推荐:所有涉及链上操作的签名都使用 eth_signTypedData_v4,消息验证(如登录)使用 personal_sign,完全禁用 eth_sign

Q3: Permit 钓鱼是如何实现的?为什么特别难防范?

答案

Permit(ERC-2612)允许用户通过离线签名授权代币转移,无需链上 approve 交易。攻击者利用这一点:

  1. 创建钓鱼页面诱导用户签署 Permit 签名
  2. 获取用户签名后,攻击者自行提交到链上
  3. 调用 transferFrom 转走用户代币

特别难防范的原因:

  • Permit 是离线操作,不产生链上交易,区块浏览器无记录
  • revoke.cash 等工具无法检测已签署但未提交的 Permit
  • 攻击者可以选择最佳时机提交签名
  • 签名本身看起来可能像正常操作

防护手段:仔细检查 EIP-712 签名中的 spender 地址、value 金额和 deadline 时间。

Q4: 为什么无限 Approve 是危险的?前端应如何处理?

答案

无限授权(approve(spender, type(uint256).max))意味着被授权的合约可以随时转走用户所有该代币,即使:

  • 合约被黑客攻破
  • 合约通过代理升级引入恶意逻辑
  • 项目方作恶(rug pull)

前端最佳实践:

  1. 精确授权:只授权本次交易所需的精确数量
  2. 检查现有额度:如果当前 allowance 足够,不重复授权
  3. 使用 Permit2:通过 Uniswap Permit2 合约统一管理授权,支持金额和时间限制
  4. 提供撤销入口:在 DApp 中集成授权管理功能,方便用户撤销

Q5: 什么是地址投毒(Address Poisoning)?如何防护?

答案

攻击者监控链上交易,使用 vanity address 生成器创建与目标用户常用地址首尾几位相同的地址,然后向用户发送小额(甚至零额)转账。

当用户从交易历史中复制地址时,可能误选攻击者地址进行大额转账。

防护措施:

  • 前端:完整显示地址,提供地址簿功能,从地址簿而非交易历史复制地址
  • 用户:始终从官方渠道获取地址,转账前仔细核对完整地址(不只看首尾)
  • 钱包:对新地址首次转账发出提醒

Q6: DApp 前端如何实现交易模拟?

答案

交易模拟通过 eth_call 在本地节点上预执行交易,不消耗 Gas 也不改变链上状态:

// 基础方案:eth_call
const result = await provider.call({
to: contractAddress,
data: encodedCalldata,
from: userAddress,
});

// 进阶方案:使用 Tenderly/Blowfish API
const simulation = await fetch('https://api.tenderly.co/simulate', {
method: 'POST',
body: JSON.stringify({ transaction: tx }),
});
// 返回详细的状态变更、资产变动、事件日志

模拟结果应展示给用户:预计的资产变动(代币增减)、Gas 消耗、调用的合约和函数、风险提示。

Q7: CSP 对 DApp 安全为什么特别重要?

答案

DApp 前端一旦被 XSS 攻击,恶意脚本可以直接调用 window.ethereum API:

// XSS 注入后,攻击者可以直接调起签名请求
window.ethereum.request({
method: 'eth_sendTransaction',
params: [maliciousTransaction],
});

因此 CSP 对 DApp 尤为重要:

  • script-src 'self':禁止内联脚本和外部脚本注入
  • connect-src 白名单:限制网络请求目标,防止数据外泄
  • frame-ancestors 'none':防止 clickjacking
  • 同时配合输入验证、输出编码等传统 XSS 防护手段

Q8: 什么是三明治攻击?前端如何帮助用户防护?

答案

三明治攻击是 MEV(最大可提取价值)攻击的一种:攻击者监控 mempool,在用户交易前后各插入一笔交易("夹住"用户),通过操纵价格获利,用户承受更差的成交价格。

前端防护措施:

  1. 滑点保护:设置合理的 minAmountOut,超出滑点限制则交易回退
  2. 使用 MEV 保护 RPC:如 Flashbots Protect,交易不进入公共 mempool
  3. 价格偏差提醒:当成交价格与市场价偏差过大时提示用户
  4. 小额交易警告:金额较小时 Gas 费占比过高,不划算

Q9: 前端代码中应该如何处理私钥和助记词?

答案

一个字:不碰。 前端代码中不应出现任何涉及私钥或助记词的逻辑:

  • 不存储:不使用 localStorage、sessionStorage、Cookie 存储私钥
  • 不传输:不通过任何 API 发送私钥
  • 不生成:不在前端生成钱包(除非是专门的钱包应用)
  • 不输入:不提供助记词输入框(这是钓鱼网站的特征)

所有签名和交易发送必须通过钱包(MetaMask、WalletConnect 等)完成。前端只负责构造交易参数和展示交易内容。

如果确实需要服务端钱包(如 relayer),私钥应存储在 HSM 或 KMS 中,前端通过认证后的 API 请求签名。

Q10: Permit2 相比传统 Approve 有什么安全优势?

答案

维度传统 ApprovePermit2
授权范围每个 DApp 单独授权,常用无限授权统一授权给 Permit2,通过签名控制每次使用
时间限制永久有效可设置 deadline 过期时间
金额控制通常无限每次签名指定精确金额
撤销管理需逐个 DApp 撤销撤销 Permit2 一个授权即可
Gas 成本每个 DApp 一次链上 approve 交易首次授权 Permit2 后,后续通过签名实现零 Gas 授权

Permit2 的核心思想是将"一次性大授权 + 多次使用"改为"一次性大授权给可信中间层 + 每次使用需签名确认"。

Q11: 如何设计一套完整的 DApp 前端安全防护体系?

答案

分层防护体系:

  1. 连接层:验证钱包连接状态和网络,参考 钱包连接方案
  2. 签名层:使用 EIP-712 结构化签名,禁用 eth_sign,清晰展示签名内容
  3. 授权层:精确授权,集成 Permit2,提供授权管理入口
  4. 交易层:交易模拟预执行,展示资产变动预览,设置滑点保护
  5. 网络层:CSP 策略、XSS 防护、域名校验、SSL 强制
  6. 监控层:异常交易告警、合约安全事件监控、用户行为异常检测

Q12: 从前端角度如何检测用户是否在访问钓鱼网站?

答案

前端本身难以判断自己是否是钓鱼网站(因为钓鱼网站可以复制所有代码),但可以从以下维度辅助:

  • EIP-712 Domain 校验:验证签名请求中的 verifyingContractchainId 是否与预期匹配
  • 合约地址白名单:前端硬编码已知安全的合约地址,拒绝与非白名单合约交互
  • ENS 验证:通过 ENS 域名反向解析验证合约身份
  • 浏览器插件协作:Pocket Universe、Wallet Guard 等插件维护钓鱼网站黑名单

更关键的是用户教育:教导用户使用书签访问、检查域名、不信任未经验证的链接。

相关链接