诈骗代币使用的一些伎俩以及如何检测它们
在本教程中,我们将剖析一个诈骗代币opens in a new tab,了解诈骗者使用的一些伎俩以及他们如何实施这些伎俩。 在本教程结束时,你将对 ERC-20 代币合约、其功能以及为何有必要保持怀疑态度有更全面的了解。 然后,我们查看该诈骗代币发出的事件,并了解如何自动识别它不是合法代币。
诈骗代币——它们是什么、人们为什么发行诈骗代币,以及如何避免它们
以太坊最常见的用途之一是由一个团队来打造一种可以交易的代币,在某种意义上是他们自己的货币。 然而,任何存在可以带来价值的合法使用场景的地方,就会有试图窃取那些价值的犯罪分子。
你可以从用户角度在 ethereum.org 的其他地方阅读更多关于此主题的内容。 本教程重点剖析一个诈骗代币,了解它是如何制作的以及如何被检测出来。
我如何知道 wARB 是个骗局?
我们剖析的代币是 wARBopens in a new tab,它伪装成与合法的 ARB 代币opens in a new tab等价。
知道哪个是合法代币的最简单方法是查看其发行组织 Arbitrumopens in a new tab。 合法地址已在他们的相关文档opens in a new tab中指定。
为什么源代码是可用的?
通常,我们期望试图诈骗他人的人会保密,事实上,许多诈骗代币的代码都不可用(例如,这个opens in a new tab和这个opens in a new tab)。
然而,合法代币通常会公布其源代码,因此为了显得合法,诈骗代币的作者有时也会这样做。 wARBopens in a new tab 是那些源代码可用的代币之一,这使得理解它变得更容易。
虽然合约部署者可以选择是否公布源代码,但他们_不能_公布错误的源代码。 区块浏览器独立编译提供的源代码,如果得不到完全相同的字节码,它就会拒绝该源代码。 你可以在 Etherscan 网站上阅读更多相关内容opens in a new tab。
与合法 ERC-20 代币的比较
我们将把这个代币与合法的 ERC-20 代币进行比较。 如果你不熟悉合法 ERC-20 代币通常是如何编写的,请参阅本教程。
特权地址的常量
合约有时需要特权地址。 为长期使用而设计的合约允许一些特权地址更改这些地址,例如,为了能够使用新的多签合约。 有几种方法可以做到这一点。
HOP 代币合约opens in a new tab 使用 Ownableopens in a new tab 模式。 特权地址保存在存储中,在一个名为 _owner 的字段中(参见第三个文件 Ownable.sol)。
1abstract contract Ownable is Context {2 address private _owner;3 .4 .5 .6}ARB 代币合约opens in a new tab没有直接的特权地址。 然而,它不需要一个。 它位于地址 0xb50721bcf8d664c30412cfbc6cf7a15145234ad1opens in a new tab 的一个 proxyopens in a new tab 之后。 该合约有一个特权地址(参见第四个文件 ERC1967Upgrade.sol),可用于升级。
1 /**2 * @dev 在 EIP1967 管理员时隙中存储一个新地址。3 */4 function _setAdmin(address newAdmin) private {5 require(newAdmin != address(0), "ERC1967: new admin is the zero address");6 StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;7 }相比之下,wARB 合约有一个硬编码的 contract_owner。
1contract WrappedArbitrum is Context, IERC20 {2 .3 .4 .5 address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;6 address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;7 .8 .9 .10}显示全部此合约所有者opens in a new tab不是一个可以在不同时间由不同帐户控制的合约,而是一个外部所有的帐户。 这意味着它可能是为个人短期使用而设计的,而不是作为控制一个将保持有价值的 ERC-20 的长期解决方案。
事实上,如果我们查看 Etherscan,我们会发现诈骗者在 2023 年 5 月 19 日期间仅使用了该合约 12 小时(从第一笔交易opens in a new tab到最后一笔交易opens in a new tab)。
虚假的 _transfer 函数
标准做法是使用一个内部的 _transfer 函数来进行实际的转账。
在 wARB 中,这个函数看起来几乎是合法的:
1 function _transfer(address sender, address recipient, uint256 amount) internal virtual{2 require(sender != address(0), "ERC20: transfer from the zero address");3 require(recipient != address(0), "ERC20: transfer to the zero address");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){10 sender = deployer;11 }12 emit Transfer(sender, recipient, amount);13 }显示全部可疑的部分是:
1 if (sender == contract_owner){2 sender = deployer;3 }4 emit Transfer(sender, recipient, amount);如果合约所有者发送代币,为什么 Transfer 事件显示它们来自 deployer?
然而,还有一个更重要的问题。 谁调用这个 _transfer 函数? 它不能从外部调用,它被标记为 internal。 而且我们拥有的代码不包含任何对 _transfer 的调用。 很明显,它在这里只是个诱饵。
1 function transfer(address recipient, uint256 amount) public virtual override returns (bool) {2 _f_(_msgSender(), recipient, amount);3 return true;4 }56 function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {7 _f_(sender, recipient, amount);8 _approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));9 return true;10 }显示全部当我们查看用于转账代币的函数 transfer 和 transferFrom 时,我们看到它们调用了一个完全不同的函数 _f_。
真正的 _f_ 函数
1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {2 require(sender != address(0), "ERC20: transfer from the zero address");3 require(recipient != address(0), "ERC20: transfer to the zero address");45 _beforeTokenTransfer(sender, recipient, amount);67 _balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");8 _balances[recipient] = _balances[recipient].add(amount);9 if (sender == contract_owner){1011 sender = deployer;12 }13 emit Transfer(sender, recipient, amount);14 }显示全部这个函数中有两个潜在的危险信号。
-
使用了函数修饰符opens in a new tab
_mod_。 然而,当我们查看源代码时,我们发现_mod_实际上是无害的。1modifier _mod_(address sender, address recipient, uint256 amount){2 _;3} -
我们在
_transfer中看到的同样问题,即当contract_owner发送代币时,它们似乎来自deployer。
虚假事件函数 dropNewTokens
现在我们来看一个看起来像真正骗局的东西。 为了便于阅读,我对函数做了一些编辑,但功能上是等效的。
1function dropNewTokens(address uPool,2 address[] memory eReceiver,3 uint256[] memory eAmounts) public auth()这个函数有 auth() 修饰符,这意味着它只能由合约所有者调用。
1modifier auth() {2 require(msg.sender == contract_owner, "Not allowed to interact");3 _;4}这个限制完全合理,因为我们不希望随机帐户分发代币。 然而,函数的其余部分是可疑的。
1{2 for (uint256 i = 0; i < eReceiver.length; i++) {3 emit Transfer(uPool, eReceiver[i], eAmounts[i]);4 }5}一个将资金从池子帐户转账到一个接收者数组(包含金额数组)的函数是完全合理的。 在许多用例中,你会希望将代币从单一来源分发到多个目的地,例如工资发放、空投等。 在单笔交易中完成比发行多笔交易更便宜(在燃料方面),甚至比在同一笔交易中从不同的合约多次调用 ERC-20 更便宜。
然而,dropNewTokens 并没有这样做。 它会发出 Transfer 事件opens in a new tab,但实际上并不转账任何代币。 没有正当理由通过告知脱链应用一笔并未真正发生的转账来迷惑它们。
销毁 Approve 函数
ERC-20 合约应该有一个用于授权的 approve 函数,我们的诈骗代币确实有这样一个函数,而且它甚至是正确的。 然而,由于 Solidity 源于 C,它是区分大小写的。 “Approve”和“approve”是不同的字符串。
此外,该功能与 approve 无关。
1 function Approve(2 address[] memory holders)此函数被调用时,会传入一个代币持有者的地址数组。
1 public approver() {approver() 修饰符确保只有 contract_owner 可以调用此函数(见下文)。
1 for (uint256 i = 0; i < holders.length; i++) {2 uint256 amount = _balances[holders[i]];3 _beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);4 _balances[holders[i]] = _balances[holders[i]].sub(amount,5 "ERC20: burn amount exceeds balance");6 _balances[0x0000000000000000000000000000000000000001] =7 _balances[0x0000000000000000000000000000000000000001].add(amount);8 }9 }10显示全部对于每个持有者地址,该函数将持有者的全部余额转移到地址 0x00...01,从而有效地销毁它(标准中的实际 burn 也会更改总供应量,并将代币转账到 0x00...00)。 这意味着 contract_owner 可以移除任何用户的资产。 这看起来不像是你希望在治理代币中拥有的功能。
代码质量问题
这些代码质量问题并不能_证明_这个代码是骗局,但它们让它看起来很可疑。 像 Arbitrum 这样的有组织的公司通常不会发布这么糟糕的代码。
mount 函数
虽然标准opens in a new tab中没有规定,但一般来说,创建新代币的函数被称为 mintopens in a new tab。
如果我们查看 wARB 的构造函数,我们会发现 mint 函数由于某种原因被重命名为 mount,并且被调用五次,每次使用初始供应量的五分之一,而不是为了效率一次性处理全部数量。
1 constructor () public {23 _name = "Wrapped Arbitrum";4 _symbol = "wARB";5 _decimals = 18;6 uint256 initialSupply = 1000000000000;78 mount(deployer, initialSupply*(10**18)/5);9 mount(deployer, initialSupply*(10**18)/5);10 mount(deployer, initialSupply*(10**18)/5);11 mount(deployer, initialSupply*(10**18)/5);12 mount(deployer, initialSupply*(10**18)/5);13 }显示全部mount 函数本身也很可疑。
1 function mount(address account, uint256 amount) public {2 require(msg.sender == contract_owner, "ERC20: mint to the zero address");查看 require,我们发现只有合约所有者被允许铸币。 这是合法的。 但是错误信息应该是_只有所有者才能铸币_或类似的东西。 相反,它使用了不相关的 ERC20: mint to the zero address。 检查是否铸币到零地址的正确测试是 require(account != address(0), "<error message>"),但该合约从未费心去检查。
1 _totalSupply = _totalSupply.add(amount);2 _balances[contract_owner] = _balances[contract_owner].add(amount);3 emit Transfer(address(0), account, amount);4 }还有两个与铸币直接相关的可疑事实:
-
有一个
account参数,大概是应该接收铸币数量的帐户。 但增加的余额实际上是contract_owner的。 -
虽然增加的余额属于
contract_owner,但发出的事件却显示了一笔向account的转账。
为什么同时有 auth 和 approver? 为什么 mod 什么都不做?
这个合约包含三个修饰符:_mod_、auth 和 approver。
1 modifier _mod_(address sender, address recipient, uint256 amount){2 _;3 }_mod_ 接收三个参数,但不对它们做任何处理。 为什么要有它?
1 modifier auth() {2 require(msg.sender == contract_owner, "Not allowed to interact");3 _;4 }56 modifier approver() {7 require(msg.sender == contract_owner, "Not allowed to interact");8 _;9 }显示全部auth 和 approver 更合理,因为它们检查合约是否由 contract_owner 调用。 我们期望某些特权操作,例如铸币,仅限于该帐户。 然而,设置两个功能_完全相同的_独立函数有什么意义呢?
我们可以自动检测到什么?
通过查看 Etherscan,我们可以看到 wARB 是一个诈骗代币。 然而,这是一个中心化的解决方案。 理论上,Etherscan 可能会被颠覆或黑客攻击。 最好能够独立判断一个代币是否合法。
我们可以通过查看它们发出的事件来使用一些技巧来识别一个 ERC-20 代币是否可疑(无论是骗局还是写得非常糟糕)。
可疑的 Approval 事件
Approval 事件opens in a new tab只应在直接请求下发生(与 Transfer 事件opens in a new tab 不同,后者可能因授权而发生)。 关于此问题的详细解释以及为何请求需要是直接的,而不是由合约介导,请参阅 Solidity 文档opens in a new tab。
这意味着批准从外部所有的帐户支出的 Approval 事件必须来自源于该帐户且目的地为 ERC-20 合约的交易。 来自外部所有的帐户的任何其他类型的批准都是可疑的。
这里有一个识别这类事件的程序opens in a new tab,它使用了 viemopens in a new tab 和 TypeScriptopens in a new tab,这是一种具有类型安全的 JavaScript 变体。 要运行它:
- 将
.env.example复制到.env。 - 编辑
.env以提供以太坊主网节点的 URL。 - 运行
pnpm install以安装必要的包。 - 运行
pnpm susApproval以查找可疑的批准。
下面是逐行解释:
1import {2 Address,3 TransactionReceipt,4 createPublicClient,5 http,6 parseAbiItem,7} from "viem"8import { mainnet } from "viem/chains"从 viem 导入类型定义、函数和链定义。
1import { config } from "dotenv"2config()读取 .env 以获取 URL。
1const client = createPublicClient({2 chain: mainnet,3 transport: http(process.env.URL),4})创建一个 Viem 客户端。 我们只需要从区块链读取数据,所以这个客户端不需要私钥。
1const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"2const fromBlock = 16859812n3const toBlock = 16873372n可疑 ERC-20 合约的地址,以及我们将在其中查找事件的区块。 节点提供商通常会限制我们读取事件的能力,因为带宽可能会变得昂贵。 幸运的是,wARB 在 18 小时内没有被使用,所以我们可以查找所有事件(总共只有 13 个)。
1const approvalEvents = await client.getLogs({2 address: testedAddress,3 fromBlock,4 toBlock,5 event: parseAbiItem(6 "event Approval(address indexed _owner, address indexed _spender, uint256 _value)"7 ),8})这是向 Viem 请求事件信息的方式。 当我们向它提供确切的事件签名,包括字段名称时,它会为我们解析事件。
1const isContract = async (addr: Address): boolean =>2 await client.getBytecode({ address: addr })我们的算法仅适用于外部所有的帐户。 如果 client.getBytecode 返回任何字节码,这意味着这是一个合约,我们应该直接跳过它。
如果你以前没有使用过 TypeScript,函数定义可能看起来有点奇怪。 我们不仅告诉它第一个(也是唯一的)参数叫做 addr,还告诉它类型是 Address。 同样,: boolean 部分告诉 TypeScript 该函数的返回值是布尔值。
1const getEventTxn = async (ev: Event): TransactionReceipt =>2 await client.getTransactionReceipt({ hash: ev.transactionHash })这个函数从一个事件中获取交易收据。 我们需要收据来确保我们知道交易的目的地是什么。
1const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {这是最重要的函数,它实际决定了一个事件是否可疑。 返回类型 (Event | null) 告诉 TypeScript 这个函数可以返回一个 Event 或 null。 如果事件不可疑,我们返回 null。
1const owner = ev.args._ownerViem 有字段名称,所以它为我们解析了事件。 _owner 是要花费的代币的所有者。
1// 合约的批准不可疑2if (await isContract(owner)) return null如果所有者是合约,则假定此批准不可疑。 要检查合约的批准是否可疑,我们需要追踪交易的完整执行过程,看它是否到达了所有者合约,以及该合约是否直接调用了 ERC-20 合约。 这比我们想做的要消耗更多资源。
1const txn = await getEventTxn(ev)如果批准来自外部所有的帐户,获取导致它的交易。
1// 如果批准来自不是交易 `from` 的 EOA 所有者,则该批准是可疑的2if (owner.toLowerCase() != txn.from.toLowerCase()) return ev我们不能只检查字符串是否相等,因为地址是十六进制的,所以它们包含字母。 有时,例如在 txn.from 中,这些字母都是小写的。 在其他情况下,例如 ev.args._owner,地址是用于错误识别的混合大小写opens in a new tab。
但是如果交易不是来自所有者,并且该所有者是外部所有的,那么我们就有一个可疑的交易。
1// 如果交易目的地不是我们正在2// 调查的 ERC-20 合约,那也是可疑的3if (txn.to.toLowerCase() != testedAddress) return ev同样,如果交易的 to 地址,即第一个被调用的合约,不是正在调查的 ERC-20 合约,那么它就是可疑的。
1 // 如果没有理由怀疑,则返回 null。2 return null3}如果两个条件都不成立,那么 Approval 事件就不可疑。
1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)34console.log(testResults)async 函数opens in a new tab返回一个 Promise 对象。 使用常见语法 await x(),我们在继续处理之前等待该 Promise 完成。 这在编程和理解上很简单,但效率也很低。 在等待特定事件的 Promise 完成时,我们已经可以开始处理下一个事件了。
这里我们使用 mapopens in a new tab 来创建一个 Promise 对象数组。 然后我们使用 Promise.allopens in a new tab 等待所有这些 promise 完成。 然后我们 filteropens in a new tab 这些结果以移除不可疑的事件。
可疑的 Transfer 事件
另一种识别诈骗代币的可能方法是查看它们是否有任何可疑的转账。 例如,从没有那么多代币的帐户进行的转账。 你可以看到如何实现这个测试opens in a new tab,但 wARB 没有这个问题。
结论
ERC-20 诈骗的自动检测存在假阴性opens in a new tab问题,因为骗局可以使用一个完全正常的 ERC-20 代币合约,而这个合约只是不代表任何真实的东西。 所以你应该总是尝试_从可信来源获取代币地址_。
自动检测在某些情况下可以提供帮助,例如在 DeFi 组件中,那里有许多代币,需要自动处理。 但一如既往,买家自负opens in a new tab,自己做研究,并鼓励你的用户也这样做。
点击此处查看我的更多作品opens in a new tab。
页面最后更新: 2025年9月4日