メインコンテンツへスキップ

詐欺トークンで使われる手口と、その見分け方

詐欺
Solidity
ERC-20
JavaScript
TypeScript
中級
Ori Pomerantz
2023年9月15日
28 分の読書

このチュートリアルでは、ある詐欺トークン (opens in a new tab)を分析し、詐欺師が使う手口やその実装方法について見ていきます。 このチュートリアルを終える頃には、ERC-20トークンコントラクト、その機能、そしてなぜ懐疑的な見方が必要なのかについて、より包括的な見解を得られるでしょう。 次に、その詐欺トークンが発行するイベントを見て、それが正当なものでないことを自動的に特定する方法を見ていきます。

詐欺トークン - それは何であり、なぜ人々はそれを行い、どうすればそれを回避できるか

イーサリアムの最も一般的な用途の1つは、グループが取引可能なトークン、いわば独自の通貨を作ることです。 価値をもたらす正当なユースケースを提供するトークンがある一方、その価値をトークン発行元が独占するようなトークンも存在します。

この件については、ethereum.org の別の場所で、ユーザーの視点から詳しく読むことができます。 このチュートリアルでは、詐欺トークンを分析し、それがどのように行われ、どのように検出できるかを見ていくことに焦点を当てます。

wARBが詐欺だとどうしてわかるのか?

私たちが分析するトークンはwARB (opens in a new tab)で、正当なARBトークン (opens in a new tab)と同等であるかのように装っています。

どちらが正当なトークンであるかを知る最も簡単な方法は、発行元の組織であるArbitrum (opens in a new tab)を見ることです。 正当なアドレスは、彼らのドキュメント (opens in a new tab)に明記されています。

なぜソースコードは利用できるのですか?

通常、他人を騙そうとする人々は秘密主義であると予想され、実際に多くの詐欺トークンはコードを公開していません(例えば、これ (opens in a new tab)これ (opens in a new tab)など)。

しかし、正当なトークンは通常ソースコードを公開するため、詐欺トークンの作者も正当に見せかけるために、同じことをすることがあります。 wARB (opens in a new tab)は、ソースコードが公開されているトークンの一つであり、そのため理解しやすくなっています。

コントラクトのデプロイ者はソースコードを公開するかどうかを選択できますが、間違ったソースコードを公開することは_できません_。 ブロックエクスプローラーは提供されたソースコードを独自にコンパイルし、全く同じバイトコードが得られなければ、そのソースコードを拒否します。 これについてはEtherscanのサイトで詳しく読むことができます (opens in a new tab)

正当なERC-20トークンとの比較

このトークンを正当なERC-20トークンと比較します。 正当なERC-20トークンが通常どのように書かれているかについて詳しくない場合は、このチュートリアルをご覧ください。

特権アドレスの定数

コントラクトには、特権アドレスが必要な場合があります。 長期的な使用を目的として設計されたコントラクトでは、一部の特権アドレスがそれらのアドレスを変更することを許可します。例えば、新しいマルチシグコントラクトの使用を可能にするためです。 これにはいくつかの方法があります。

HOPトークンコントラクト (opens in a new tab)は、Ownable (opens in a new tab)パターンを使用しています。 特権アドレスは、_ownerと呼ばれるフィールドのストレージに保持されます(3番目のファイルOwnable.solを参照)。

1abstract contract Ownable is Context {
2 address private _owner;
3 .
4 .
5 .
6}

ARBトークンコントラクト (opens in a new tab)には、直接的な特権アドレスはありません。 しかし、それは必要ありません。 それは、アドレス0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab)にあるproxy (opens in a new tab)の背後にあります。 そのコントラクトには、アップグレードに使用できる特権アドレスがあります(4番目のファイル、ERC1967Upgrade.solを参照)。

1 /**
2 * @dev EIP1967の管理者スロットに新しいアドレスを格納します。
3 */
4 function _setAdmin(address newAdmin) private {
5 require(newAdmin != address(0), "ERC1967: 新しい管理者はゼロアドレスです");
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}
すべて表示

このコントラクトオーナーは、異なる時点で異なるアカウントによって制御されうるコントラクトではなく、外部所有アカウントです。 これは、価値を維持し続ける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: ゼロアドレスからの送金");
3 require(recipient != address(0), "ERC20: ゼロアドレスへの送金");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: 送金額が残高を超えています");
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 }
5
6 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: 送金額が許容量を超えています"));
9 return true;
10 }
すべて表示

トークンを転送するために呼び出される関数transfertransferFromを見ると、それらが全く異なる関数_f_を呼び出していることがわかります。

本当の_f_関数

1 function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {
2 require(sender != address(0), "ERC20: ゼロアドレスからの送金");
3 require(recipient != address(0), "ERC20: ゼロアドレスへの送金");
4
5 _beforeTokenTransfer(sender, recipient, amount);
6
7 _balances[sender] = _balances[sender].sub(amount, "ERC20: 送金額が残高を超えています");
8 _balances[recipient] = _balances[recipient].add(amount);
9 if (sender == contract_owner){
10
11 sender = deployer;
12 }
13 emit Transfer(sender, recipient, amount);
14 }
すべて表示

この関数には2つの潜在的な危険信号があります。

  • 関数修飾子 (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, "対話は許可されていません");
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コントラクトは、許容量のためにan approve functionを持つことになっており、実際、私たちの詐欺トークンにはそのような関数があり、しかも正しいものです。 しかし、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)には明記されていませんが、一般的に新しいトークンを作成する関数はmintと呼ばれます。

wARBのコンストラクタを見ると、ミント関数が何らかの理由でmountに改名されており、効率化のために全額を一度にではなく、初期供給の5分の1で5回呼び出されていることがわかります。

1 constructor () public {
2
3 _name = "Wrapped Arbitrum";
4 _symbol = "wARB";
5 _decimals = 18;
6 uint256 initialSupply = 1000000000000;
7
8 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を見ると、コントラクトオーナーだけがミントすることを許可されていることがわかります。 これは正当です。 しかし、エラーメッセージは_only owner is allowed to mint_(オーナーのみミント可能)などであるべきです。 代わりに、それは無関係な_ERC20: mint to the zero address_です。 ゼロアドレスへのミントを正しくテストするには require(account != address(0), "<エラーメッセージ>") としますが、コントラクトはこれをチェックする手間をかけていません。

1 _totalSupply = _totalSupply.add(amount);
2 _balances[contract_owner] = _balances[contract_owner].add(amount);
3 emit Transfer(address(0), account, amount);
4 }

さらに2つ、直接ミントに関連する疑わしい事実があります。

  • accountパラメータがあり、これはミントされた量を受け取るべきアカウントであると推測されます。 しかし、実際に増加する残高はcontract_ownerのものです。

  • 増加した残高はcontract_ownerのものですが、発行されたイベントはaccountへの送金を示しています。

なぜ authapprover の両方があるのか? なぜ何もしないmodがあるのか?

このコントラクトには _mod_authapprover の3つの修飾子が含まれています。

1 modifier _mod_(address sender, address recipient, uint256 amount){
2 _;
3 }

_mod_は3つのパラメータを取り、それらで何もしません。 なぜそれがあるのでしょうか?

1 modifier auth() {
2 require(msg.sender == contract_owner, "対話は許可されていません");
3 _;
4 }
5
6 modifier approver() {
7 require(msg.sender == contract_owner, "対話は許可されていません");
8 _;
9 }
すべて表示

authapprover は、コントラクトが contract_owner によって呼び出されたことを確認するため、より理にかなっています。 ミントなどの特定の特権的なアクションは、そのアカウントに限定されることを期待します。 しかし、_全く同じこと_をする2つの別々の関数を持つことに何の意味があるのでしょうか?

何を自動的に検出できるか?

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コントラクトであるトランザクションからでなければならないことを意味します。 外部所有アカウントからのその他の種類の承認は疑わしいです。

viem (opens in a new tab)と、型安全性を備えたJavaScriptの派生言語であるTypeScript (opens in a new tab)を使用して、この種のイベントを特定するプログラム (opens in a new tab)がここにあります。 実行方法:

  1. .env.example.env にコピーします。
  2. .env を編集して、イーサリアムメインネットノードへのURLを提供します。
  3. pnpm install を実行して、必要なパッケージをインストールします。
  4. 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 = 16859812n
3const 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) は、この関数が Event または null のいずれかを返すことができることを TypeScript に伝えます。 イベントが疑わしくない場合はnullを返します。

1const owner = ev.args._owner

Viemはフィールド名を持っているので、イベントを解析してくれました。 _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

アドレスは16進数なので文字が含まれているため、単純に文字列の等価性をチェックすることはできません。 例えば、txn.fromでは、それらの文字はすべて小文字です。 ev.args._ownerのような他のケースでは、アドレスはエラー識別のために大文字と小文字が混在しています (opens in a new tab)

しかし、トランザクションが所有者からのものではなく、その所有者が外部所有である場合、それは疑わしいトランザクションです。

1// トランザクションの宛先が調査中のERC-20コントラクトでない場合も
2// 疑わしいです
3if (txn.to.toLowerCase() != testedAddress) return ev

同様に、トランザクションのtoアドレス、つまり最初に呼び出されたコントラクトが、調査対象のERC-20コントラクトでない場合も疑わしいです。

1 // 疑わしい理由がない場合は、nullを返します。
2 return null
3}

どちらの条件も真でない場合、Approvalイベントは疑わしくありません。

1const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
2const testResults = (await Promise.all(testPromises)).filter((x) => x != null)
3
4console.log(testResults)

async関数 (opens in a new tab)Promiseオブジェクトを返します。 一般的な構文 await x() を使用すると、その Promise が満たされるまで待ってから処理を続行します。 これはプログラムしやすく、追いやすいですが、非効率的でもあります。 特定のイベントのPromiseが満たされるのを待っている間に、次のイベントの作業にすでに取りかかることができます。

ここではmap (opens in a new tab)を使用してPromiseオブジェクトの配列を作成します。 次に、Promise.all (opens in a new tab)を使用して、それらのすべてのプロミスが解決されるのを待ちます。 その後、それらの結果をfilter (opens in a new tab)して、疑わしくないイベントを削除します。

疑わしいTransferイベント

詐欺トークンを特定するもう一つの可能性のある方法は、疑わしい送金があるかどうかを確認することです。 例えば、それほど多くのトークンを持っていないアカウントからの送金などです。 このテストの実装方法 (opens in a new tab)を見ることができますが、wARBにはこの問題はありません。

結論

詐欺が完全に正常なERC-20トークンコントラクトを使用し、それが何も実体を表していないだけの場合があるため、ERC-20詐欺の自動検出は偽陰性 (opens in a new tab)に悩まされます。 したがって、常に_信頼できる情報源からトークンアドレスを取得する_ように努めるべきです。

自動検出は、DeFiピースのような、多くのトークンがあり、それらを自動的に処理する必要がある特定のケースで役立ちます。 しかし、いつものようにcaveat emptor (opens in a new tab)(買い手注意)、自分で調査し、ユーザーにも同様に行うよう奨励してください。

私の他の作品はこちらでご覧いただけます (opens in a new tab).

最終更新: 2026年2月25日

このチュートリアルは役に立ちましたか?