跳转到内容
123xiao | 无名键客

《Web3 中级实战:基于 EIP-712 与钱包签名实现去中心化登录(SIWE)完整方案》

字数: 0 阅读时长: 1 分钟

Web3 中级实战:基于 EIP-712 与钱包签名实现去中心化登录(SIWE)完整方案

在 Web3 应用里,“登录”这件事和传统系统很不一样。

我们习惯了用户名 + 密码,或者手机号验证码,但到了链上世界,用户真正掌握身份的方式,其实是钱包私钥。这就引出一个很自然的问题:

能不能不再存密码,而是直接让用户用钱包签名完成登录?

答案当然是可以,而且这已经是很多 DApp 的标准姿势。本文我会带你从业务背景、EIP-712 原理、后端验签、会话建立、安全细节一路走完,做出一个可运行的 SIWE(Sign-In with Ethereum)登录方案。

这篇文章偏中级实战,我默认你已经对钱包、签名、公私钥这些概念不陌生,但如果你还没真正把“钱包签名登录”从前端到后端串起来,这篇会比较合适。


一、背景与问题

1.1 传统登录为什么不适合 Web3

在传统 Web 应用中,身份认证通常依赖:

  • 用户名/密码
  • 短信验证码
  • OAuth 第三方登录
  • 服务端保存账户体系

但在 Web3 里,很多用户天然已经有了自己的身份载体:钱包地址
如果还要求他再注册一个账号、设置一遍密码,体验会非常割裂。

更关键的是:

  • 密码体系增加了服务端泄漏风险
  • Web3 用户更习惯“连接钱包即身份”
  • 链上地址本身可作为统一身份标识

所以去中心化登录的核心诉求就是:

  1. 用户证明“我确实控制这个地址”
  2. 服务端验证签名合法
  3. 服务端发放会话或 Token
  4. 后续请求沿用这个会话

1.2 为什么不能直接“签一段字符串”就完了

很多早期教程会这么写:

Please sign this message to login: 123456

这看起来简单,但有几个问题:

  • 消息结构不规范,前后端容易约定混乱
  • 可读性差,用户在钱包里看不清签了什么
  • 容易被重放攻击
  • 域信息(domain、站点、chainId)不明确
  • 后续扩展字段困难

因此,中级以上项目我更建议直接采用结构化签名,也就是 EIP-712
而如果你希望贴近登录语义,则可以把 SIWE(EIP-4361) 的思路和 EIP-712 结合起来实现。


二、前置知识与环境准备

本文示例技术栈:

  • 前端:React + Vite
  • 钱包接入:MetaMask / 兼容 EIP-1193 的钱包
  • 后端:Node.js + Express
  • 签名库:ethers v6
  • 会话:JWT(也可以换成 HttpOnly Cookie)
  • 数据存储:内存示例,生产建议 Redis / DB

安装依赖如下。

前端

npm install ethers

后端

npm install express cors ethers jsonwebtoken

如果你喜欢 TypeScript,也可以自行补上类型定义。为了让代码更直观,本文用 JavaScript 展示。


三、核心原理

先把整条链路捋顺。去中心化登录本质上不是“链上交易”,而是离线签名 + 服务端验签

3.1 整体流程

flowchart TD
    A[用户连接钱包] --> B[前端请求服务端生成 nonce]
    B --> C[服务端返回 nonce 与登录上下文]
    C --> D[前端构造 EIP-712 Typed Data]
    D --> E[钱包弹窗请求签名]
    E --> F[前端提交 address + signature + message]
    F --> G[服务端使用 EIP-712 验签]
    G --> H{验签通过且 nonce 未使用?}
    H -- 是 --> I[标记 nonce 已消费]
    I --> J[签发 JWT / Session]
    H -- 否 --> K[拒绝登录]

3.2 EIP-712 到底解决了什么

EIP-712 的核心价值是:让签名内容结构化、可读、可验证、可扩展

和普通字符串签名相比,它包含三部分:

  • domain:签名域,限定应用上下文
  • types:结构体定义
  • message:待签名的数据内容

这样做的好处:

  1. 钱包能更友好地展示签名内容
  2. 服务端可以严格按结构验签
  3. 能天然纳入 chainIddomainurinonceissuedAt 等字段
  4. 更适合登录、授权、Permit 等场景

3.3 SIWE 在这里扮演什么角色

SIWE(Sign-In with Ethereum)强调的是一种登录消息规范
它告诉你,一个“以太坊登录消息”应该有哪些关键字段,例如:

  • domain
  • address
  • statement
  • uri
  • version
  • chainId
  • nonce
  • issuedAt
  • expirationTime

严格来说,SIWE 传统上更常见的是基于可读文本格式的签名消息;而本文采用的做法是:

借鉴 SIWE 的字段语义,用 EIP-712 来承载结构化登录消息。

这在工程上非常实用:
既有 SIWE 的登录语义,又有 EIP-712 的结构化优势。


3.4 登录消息结构设计

这里我们定义一个 Login 类型:

const types = {
  Login: [
    { name: 'domain', type: 'string' },
    { name: 'address', type: 'address' },
    { name: 'statement', type: 'string' },
    { name: 'uri', type: 'string' },
    { name: 'version', type: 'string' },
    { name: 'chainId', type: 'uint256' },
    { name: 'nonce', type: 'string' },
    { name: 'issuedAt', type: 'string' },
    { name: 'expirationTime', type: 'string' }
  ]
}

其中最关键的安全字段是:

  • nonce:防重放
  • issuedAt:签发时间
  • expirationTime:过期时间
  • chainId:限制链环境
  • domain / uri:限制业务上下文

3.5 验签原理

EIP-712 验签的本质是:

  1. 服务端拿到 typed data
  2. 对 typed data 做相同哈希
  3. 用签名恢复出签名者地址
  4. 比较恢复地址是否等于用户声称的地址

流程如下:

sequenceDiagram
    participant U as 用户钱包
    participant F as 前端
    participant S as 服务端

    F->>S: GET /auth/nonce?address=0x...
    S-->>F: nonce + issuedAt + expirationTime
    F->>U: signTypedData(domain, types, message)
    U-->>F: signature
    F->>S: POST /auth/verify {message, signature}
    S->>S: verifyTypedData(...)
    S->>S: 校验 nonce / 时间 / domain / chainId
    S-->>F: JWT / Session

四、实战代码(可运行)

下面我们直接做一个最小可运行版本。

目录结构:

web3-siwe-demo/
  ├─ server.js
  └─ frontend-example.js

这里前端代码我会写成一个独立示例函数,方便你直接迁移到 React/Vue 项目里。


4.1 后端:生成 nonce 与验签登录

server.js

const express = require('express')
const cors = require('cors')
const crypto = require('crypto')
const jwt = require('jsonwebtoken')
const { ethers } = require('ethers')

const app = express()
app.use(cors())
app.use(express.json())

const PORT = 3001
const JWT_SECRET = 'replace-this-in-production'

/**
 * 简化演示:
 * nonceStore 记录每个 address 当前可用 nonce
 * usedNonceStore 记录已消费 nonce
 *
 * 生产环境建议:
 * - Redis 存储
 * - 设置 TTL
 * - nonce 一次性消费
 */
const nonceStore = new Map()
const usedNonceStore = new Set()

function generateNonce() {
  return crypto.randomBytes(16).toString('hex')
}

function nowISO() {
  return new Date().toISOString()
}

function plusMinutesISO(minutes) {
  return new Date(Date.now() + minutes * 60 * 1000).toISOString()
}

function getDomain(chainId) {
  return {
    name: 'Demo SIWE App',
    version: '1',
    chainId,
    verifyingContract: '0x0000000000000000000000000000000000000000'
  }
}

function getTypes() {
  return {
    Login: [
      { name: 'domain', type: 'string' },
      { name: 'address', type: 'address' },
      { name: 'statement', type: 'string' },
      { name: 'uri', type: 'string' },
      { name: 'version', type: 'string' },
      { name: 'chainId', type: 'uint256' },
      { name: 'nonce', type: 'string' },
      { name: 'issuedAt', type: 'string' },
      { name: 'expirationTime', type: 'string' }
    ]
  }
}

app.get('/auth/nonce', (req, res) => {
  const { address, chainId } = req.query

  if (!address || !ethers.isAddress(address)) {
    return res.status(400).json({ error: 'Invalid address' })
  }

  const parsedChainId = Number(chainId || 1)
  if (!Number.isInteger(parsedChainId) || parsedChainId <= 0) {
    return res.status(400).json({ error: 'Invalid chainId' })
  }

  const nonce = generateNonce()
  const issuedAt = nowISO()
  const expirationTime = plusMinutesISO(10)

  nonceStore.set(address.toLowerCase(), {
    nonce,
    issuedAt,
    expirationTime,
    chainId: parsedChainId
  })

  return res.json({
    nonce,
    issuedAt,
    expirationTime,
    domainData: getDomain(parsedChainId),
    types: getTypes()
  })
})

app.post('/auth/verify', (req, res) => {
  try {
    const { message, signature } = req.body

    if (!message || !signature) {
      return res.status(400).json({ error: 'Missing message or signature' })
    }

    const {
      domain,
      address,
      statement,
      uri,
      version,
      chainId,
      nonce,
      issuedAt,
      expirationTime
    } = message

    if (!ethers.isAddress(address)) {
      return res.status(400).json({ error: 'Invalid address in message' })
    }

    const stored = nonceStore.get(address.toLowerCase())
    if (!stored) {
      return res.status(400).json({ error: 'Nonce not found, request nonce first' })
    }

    if (stored.nonce !== nonce) {
      return res.status(400).json({ error: 'Nonce mismatch' })
    }

    if (stored.chainId !== Number(chainId)) {
      return res.status(400).json({ error: 'ChainId mismatch' })
    }

    if (usedNonceStore.has(`${address.toLowerCase()}:${nonce}`)) {
      return res.status(400).json({ error: 'Nonce already used' })
    }

    const now = Date.now()
    if (new Date(expirationTime).getTime() < now) {
      return res.status(400).json({ error: 'Message expired' })
    }

    if (new Date(issuedAt).getTime() - now > 5 * 60 * 1000) {
      return res.status(400).json({ error: 'IssuedAt is too far in the future' })
    }

    // 可额外校验业务字段
    if (domain !== 'localhost:5173') {
      return res.status(400).json({ error: 'Invalid domain' })
    }

    if (uri !== 'http://localhost:5173') {
      return res.status(400).json({ error: 'Invalid uri' })
    }

    if (version !== '1') {
      return res.status(400).json({ error: 'Invalid version' })
    }

    if (statement !== 'Sign in to Demo SIWE App') {
      return res.status(400).json({ error: 'Invalid statement' })
    }

    const recoveredAddress = ethers.verifyTypedData(
      getDomain(Number(chainId)),
      getTypes(),
      message,
      signature
    )

    if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
      return res.status(401).json({ error: 'Signature verification failed' })
    }

    usedNonceStore.add(`${address.toLowerCase()}:${nonce}`)
    nonceStore.delete(address.toLowerCase())

    const token = jwt.sign(
      {
        sub: address.toLowerCase(),
        wallet: address.toLowerCase(),
        chainId: Number(chainId)
      },
      JWT_SECRET,
      { expiresIn: '2h' }
    )

    return res.json({
      success: true,
      token,
      address: address.toLowerCase()
    })
  } catch (err) {
    console.error(err)
    return res.status(500).json({ error: 'Internal server error' })
  }
})

app.get('/me', (req, res) => {
  const auth = req.headers.authorization || ''
  const token = auth.startsWith('Bearer ') ? auth.slice(7) : null

  if (!token) {
    return res.status(401).json({ error: 'Missing token' })
  }

  try {
    const payload = jwt.verify(token, JWT_SECRET)
    return res.json({ user: payload })
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' })
  }
})

app.listen(PORT, () => {
  console.log(`Server running at http://localhost:${PORT}`)
})

启动:

node server.js

4.2 前端:连接钱包并发起 EIP-712 登录

frontend-example.js

import { ethers } from 'ethers'

const API_BASE = 'http://localhost:3001'

export async function loginWithWallet() {
  if (!window.ethereum) {
    throw new Error('MetaMask not found')
  }

  const provider = new ethers.BrowserProvider(window.ethereum)

  await provider.send('eth_requestAccounts', [])
  const signer = await provider.getSigner()

  const address = await signer.getAddress()
  const network = await provider.getNetwork()
  const chainId = Number(network.chainId)

  // 1. 获取后端生成的 nonce
  const nonceResp = await fetch(
    `${API_BASE}/auth/nonce?address=${address}&chainId=${chainId}`
  )
  const nonceData = await nonceResp.json()

  if (!nonceResp.ok) {
    throw new Error(nonceData.error || 'Failed to get nonce')
  }

  const domain = nonceData.domainData
  const types = nonceData.types

  // 2. 构造结构化登录消息
  const message = {
    domain: window.location.host,
    address,
    statement: 'Sign in to Demo SIWE App',
    uri: window.location.origin,
    version: '1',
    chainId,
    nonce: nonceData.nonce,
    issuedAt: nonceData.issuedAt,
    expirationTime: nonceData.expirationTime
  }

  // 3. 发起 EIP-712 签名
  const signature = await signer.signTypedData(domain, types, message)

  // 4. 提交服务端验签
  const verifyResp = await fetch(`${API_BASE}/auth/verify`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      message,
      signature
    })
  })

  const verifyData = await verifyResp.json()

  if (!verifyResp.ok) {
    throw new Error(verifyData.error || 'Login failed')
  }

  localStorage.setItem('token', verifyData.token)

  return verifyData
}

export async function getCurrentUser() {
  const token = localStorage.getItem('token')
  if (!token) throw new Error('Not logged in')

  const resp = await fetch(`${API_BASE}/me`, {
    headers: {
      Authorization: `Bearer ${token}`
    }
  })

  const data = await resp.json()
  if (!resp.ok) {
    throw new Error(data.error || 'Failed to get user')
  }

  return data
}

4.3 React 页面最小接入示例

如果你在 React 里测试,可以这样写:

import React, { useState } from 'react'
import { loginWithWallet, getCurrentUser } from './frontend-example'

export default function App() {
  const [result, setResult] = useState(null)
  const [error, setError] = useState('')

  const handleLogin = async () => {
    try {
      setError('')
      const data = await loginWithWallet()
      setResult(data)
    } catch (e) {
      setError(e.message)
    }
  }

  const handleMe = async () => {
    try {
      setError('')
      const data = await getCurrentUser()
      setResult(data)
    } catch (e) {
      setError(e.message)
    }
  }

  return (
    <div style={{ padding: 24 }}>
      <h1>Demo SIWE Login</h1>
      <button onClick={handleLogin}>钱包登录</button>
      <button onClick={handleMe} style={{ marginLeft: 12 }}>
        获取当前用户
      </button>

      {error && <pre style={{ color: 'red' }}>{error}</pre>}
      {result && <pre>{JSON.stringify(result, null, 2)}</pre>}
    </div>
  )
}

4.4 逐步验证清单

建议你按下面顺序验证,而不是一把梭哈上线。我自己做这类功能时,通常都这么拆。

第一步:确认钱包连接成功

检查:

  • eth_requestAccounts 是否弹窗
  • 是否拿到了正确地址
  • 当前 chainId 是否符合预期

第二步:确认 nonce 接口返回合理

检查服务端是否返回:

  • nonce
  • issuedAt
  • expirationTime
  • domainData
  • types

第三步:确认钱包成功弹出签名框

检查钱包签名界面里是否能看到:

  • 登录语句
  • 地址
  • 域名
  • nonce
  • 有效时间

第四步:确认服务端验签通过

关注服务端日志:

  • 是否拿到 messagesignature
  • verifyTypedData 是否恢复出正确地址
  • nonce 是否被成功消费

第五步:确认登录后会话可用

验证:

  • JWT 是否成功保存
  • /me 接口是否能返回用户身份
  • 过期后是否正确拒绝访问

五、常见坑与排查

这一段很重要。EIP-712 登录最烦人的地方不是“不会写”,而是“看起来都对,但就是验不过”。


5.1 domain 不一致导致验签失败

最常见的问题之一是:

  • 前端签名时用的 domain
  • 后端验签时构造的 domain

这两者只要有一个字段不同,验签就会失败。

比如这些字段都必须严格一致:

  • name
  • version
  • chainId
  • verifyingContract

很多人会忽略 verifyingContract
如果前端有,后端没有;或者前端是某个地址,后端是零地址,都会失败。

排查建议

把前端签名前的 domain/types/message 完整打印出来,同时在后端也打印一份,逐字段比对。


5.2 types 定义顺序或字段类型不一致

例如前端:

{ name: 'chainId', type: 'uint256' }

后端误写成:

{ name: 'chainId', type: 'string' }

这会直接导致恢复地址不一致。

还有一个容易忽略的点:字段顺序也必须一致
不要觉得 JSON 是无序的,EIP-712 的结构定义顺序是有意义的。


5.3 地址大小写导致比较误判

恢复出来的地址可能是 checksum 格式,而你保存的是全小写。

错误写法:

if (recoveredAddress !== address) { ... }

正确写法:

if (recoveredAddress.toLowerCase() !== address.toLowerCase()) { ... }

当然,如果你想更严谨,也可以统一走 ethers.getAddress() 规范化。


5.4 nonce 没有一次性消费,存在重放风险

如果你只做了“验签正确就登录”,却没有处理 nonce,那么攻击者可能会复用同一份签名反复请求。

正确做法:

  • nonce 服务端生成
  • nonce 设置有效期
  • 验签成功后立即消费
  • 同一 nonce 不允许二次使用

这个坑我见过不少 Demo 都会漏,Demo 没关系,生产环境一定不能漏。


5.5 前端链 ID 和后端链 ID 不一致

比如用户当前钱包在 Polygon,但你服务端假定的是 Ethereum Mainnet。
这时即使签名本身合法,也应该拒绝这次登录,避免上下文混乱。

建议

  • nonce 接口把允许的 chainId 范围返回给前端
  • 前端先检查网络,不匹配时提示用户切换链
  • 后端再次做最终校验,不信任前端

5.6 时间字段校验太松或太死

如果你完全不校验 issuedAt / expirationTime,签名会长期有效。
但如果校验过于严格,比如只允许 10 秒误差,也可能因为客户端时间漂移导致大量误判。

实战建议

  • expirationTime 控制在 5~10 分钟
  • issuedAt 允许少量时间偏差
  • 服务端使用自己的时间作为最终裁判

5.7 钱包对 EIP-712 支持差异

大多数主流钱包支持 eth_signTypedData_v4,但不同钱包在 UI 展示、字段兼容性上会有差异。

建议

  • 优先使用 ethers 的 signTypedData
  • 在测试环境至少覆盖 MetaMask、OKX Wallet、Rabby 这类主流钱包
  • 尽量避免过于复杂的嵌套结构,登录消息保持扁平

六、安全/性能最佳实践

这一节我会把“能跑”和“能上生产”之间的差距讲清楚。


6.1 nonce 存 Redis,不要只放内存

本文为了演示用了 Map,但生产环境不够用:

  • 服务重启会丢失 nonce
  • 多实例部署无法共享
  • 无法自然设置 TTL
  • 不方便做消费状态管理

生产建议:

  • SETEX nonce:{address} value ttl
  • 验签成功后删除
  • 或用事务保证“读取 + 消费”的原子性

JWT 放 localStorage 很方便,但有 XSS 风险。
如果你的站点是常规 Web 应用,通常更推荐:

  • 登录成功后服务端 Set-Cookie
  • HttpOnly
  • Secure
  • SameSite=Lax/Strict

如果你确实是前后端完全分离、跨域部署,才更常见用 Bearer Token。

一个实用原则

  • 后台管理类、Web 产品:优先 Cookie Session
  • API 网关、多端接入:可考虑 JWT,但要补好刷新机制和风控

6.3 域隔离必须严格

签名消息里最好明确这些字段:

  • domain
  • uri
  • chainId
  • statement

这相当于告诉用户:

你是在什么站点、为了什么目的、在哪条链环境下登录。

如果你省略这些信息,虽然也能做签名验证,但用户几乎无法判断自己到底签了什么。


6.4 限制签名有效期

一个实用配置:

  • nonce 有效期:5~10 分钟
  • 签名消息有效期:5~10 分钟
  • 登录态有效期:2 小时 ~ 7 天,视业务风险而定
  • 高风险操作再要求二次签名

注意:登录签名交易签名不是一回事。
登录成功不代表用户授权你发交易,涉及资产操作时必须单独确认。


6.5 绑定业务上下文,避免“签名串用”

如果你的系统有多个子站点、多租户、多个环境,建议把环境信息纳入消息。

例如:

  • domain: app.example.com
  • uri: https://app.example.com
  • statement: Sign in to Example Pro
  • 或增加 resources / tenantId 字段

这样可以防止测试环境签名误用于生产环境,或 A 站点签名误用于 B 站点。


6.6 为高频登录做缓存和限流

性能层面,验签本身不算特别重,但高并发下仍要注意:

  • nonce 接口限流
  • 登录接口限流
  • 对同地址短时间请求次数限制
  • 用户资料读取缓存
  • JWT 黑名单/撤销策略

可以参考下面这个状态流转:

stateDiagram-v2
    [*] --> 未登录
    未登录 --> 已获取Nonce: 请求 nonce
    已获取Nonce --> 已签名待验证: 钱包签名成功
    已签名待验证 --> 已登录: 验签通过 + 消费 nonce
    已签名待验证 --> 未登录: 验签失败/过期
    已登录 --> 已过期: token 失效
    已过期 --> 未登录: 重新登录

6.7 不要把“地址存在”当成“用户可信”

钱包地址只能说明:

当前这个用户控制这个私钥。

它并不能说明:

  • 这个用户不是机器人
  • 这个地址没有被盗
  • 这个地址没有风险行为
  • 这个地址一定有某种业务权限

所以实际业务里,常见会叠加:

  • 风控
  • 黑名单
  • 链上画像
  • NFT / Token 持仓校验
  • 多签名确认
  • 二次交互确认

登录只是认证起点,不是完整信任体系。


七、方案补充:EIP-191 文本签名 vs EIP-712 结构化签名

很多团队会纠结:登录到底要不要上 EIP-712?

我的经验是这样:

适合继续用普通文本签名的情况

  • 你在做 MVP
  • 钱包兼容性优先
  • 消息结构简单
  • 后续不准备扩展复杂字段

更适合用 EIP-712 的情况

  • 你希望签名内容结构清晰
  • 你需要更强的上下文约束
  • 你要对接多个前端/服务端
  • 你希望后续扩展授权、委托、资源访问等场景

简单理解:

  • EIP-191:上手快,但容易野生增长
  • EIP-712:前期多一点约束,长期维护舒服很多

如果是中级以上项目,我一般更推荐 EIP-712。


八、一个更像生产环境的落地建议

如果你准备把这套方案真正上业务,可以按这个分层来做:

flowchart LR
    A[前端 DApp] --> B[认证网关 Auth Service]
    B --> C[Nonce/Session Redis]
    B --> D[用户数据库]
    B --> E[风控服务]
    B --> F[业务 API]

    A -->|EIP-712 签名| B
    B -->|JWT/Cookie| A

推荐职责划分

前端

  • 连接钱包
  • 获取 nonce
  • 发起签名
  • 提交签名结果
  • 保存登录态

认证服务

  • 生成 nonce
  • 统一校验 typed data
  • 验签
  • 签发 session/JWT
  • 记录登录审计

业务服务

  • 只关心认证后的用户身份
  • 不重复实现签名验签逻辑

这样做有个好处:
你的登录能力会从某个 DApp 页面抽象成平台能力,后面接更多站点会省很多事。


九、总结

我们把一套完整的钱包签名登录方案走通了,核心就三件事:

  1. 服务端生成一次性 nonce
  2. 前端用 EIP-712 构造结构化登录消息并让钱包签名
  3. 服务端严格校验消息内容、时间、域、链和签名,再签发会话

如果你只记住几个最关键的落地点,我建议是:

  • 登录消息一定带 nonce
  • nonce 一定一次性消费
  • 前后端 domain/types/message 必须完全一致
  • 校验 chainId/domain/uri/expirationTime
  • 生产环境不要用内存存 nonce
  • 有条件优先使用 HttpOnly Cookie 管理登录态

最后给一个很务实的边界建议:

  • 如果你在做 Demo 或黑客松项目,先把最小链路跑通
  • 如果你在做正式产品,至少把 nonce、过期时间、会话安全、限流、日志审计补齐
  • 如果涉及资产操作,不要把“登录签名”误当成“交易授权”

登录是入口,安全是底线。
把这套方案做扎实,后面的链上身份、权限控制、资源授权都会顺很多。


分享到:

上一篇
《自动化测试中的测试数据治理实践:从数据构造、隔离到回收的落地方案》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 的应用签名校验与反调试绕过分析》