詐欺トークンで使われる手口とその検出方法
このチュートリアルでは、詐欺トークン (opens in a new tab)を解剖し、詐欺師が使う手口とその実装方法について見ていきます。このチュートリアルの終わりには、ERC-20トークンのコントラクト、その機能、そしてなぜ懐疑的であることが必要なのかについて、より包括的な視点を持てるようになるでしょう。その後、その詐欺トークンが発行するイベントを見て、それが正当なものではないことを自動的に識別する方法を確認します。
詐欺トークン - その正体、作成される理由、そして回避する方法
イーサリアムの最も一般的な用途の1つは、グループが取引可能なトークン、ある意味で独自の通貨を作成することです。しかし、価値をもたらす正当なユースケースがあるところには必ず、その価値を自分たちのために盗もうとする犯罪者も存在します。
ユーザーの視点からこのテーマについて詳しく知りたい場合は、ethereum.orgの他のページをお読みください。このチュートリアルでは、詐欺トークンを解剖し、それがどのように作られ、どのように検出できるかに焦点を当てます。
wARBが詐欺であるとどうやって見分けるのか?
今回解剖するトークンはwARB (opens in a new tab)で、これは正当なARBトークン (opens in a new tab)と同等であると偽っています。
どちらが正当なトークンかを知る最も簡単な方法は、発行元の組織であるアービトラム (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)はソースコードが公開されているトークンの1つであり、その仕組みを理解しやすくなっています。
コントラクトのデプロイ担当者はソースコードを公開するかどうかを選択できますが、間違ったソースコードを公開することは_できません_。ブロック・エクスプローラーは提供されたソースコードを独立してコンパイルし、全く同じバイトコードが得られない場合は、そのソースコードを拒否します。これについての詳細は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を参照)。
abstract contract Ownable is Context {
address private _owner;
.
.
.
}
ARBトークンのコントラクト (opens in a new tab)は、直接的には特権アドレスを持っていません。しかし、それは必要ありません。このコントラクトは、アドレス0xb50721bcf8d664c30412cfbc6cf7a15145234ad1 (opens in a new tab)にあるproxy (opens in a new tab)の背後に配置されています。そのコントラクトには、アップグレードに使用できる特権アドレスがあります(4番目のファイル、ERC1967Upgrade.solを参照)。
/**
* @dev EIP1967の管理スロットに新しいアドレスを保存します。
*/
function _setAdmin(address newAdmin) private {
require(newAdmin != address(0), "ERC1967: new admin is the zero address");
StorageSlot.getAddressSlot(_ADMIN_SLOT).value = newAdmin;
}
対照的に、wARBコントラクトにはハードコードされたcontract_ownerがあります。
contract WrappedArbitrum is Context, IERC20 {
.
.
.
address deployer = 0xB50721BCf8d664c30412Cfbc6cf7a15145234ad1;
address public contract_owner = 0xb40dE7b1beE84Ff2dc22B70a049A07A13a411A33;
.
.
.
}
このコントラクトの所有者 (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では、この関数はほぼ正当なものに見えます。
function _transfer(address sender, address recipient, uint256 amount) internal virtual{
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
}
疑わしい部分は以下の通りです。
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
コントラクトの所有者がトークンを送金する場合、なぜTransferイベントはそれがdeployerから来ていると示すのでしょうか?
しかし、もっと重要な問題があります。誰がこの_transfer関数を呼び出すのでしょうか?internalとマークされているため、外部から呼び出すことはできません。そして、私たちが持っているコードには_transferの呼び出しは含まれていません。明らかに、これはおとりとしてここにあります。
function transfer(address recipient, uint256 amount) public virtual override returns (bool) {
_f_(_msgSender(), recipient, amount);
return true;
}
function transferFrom(address sender, address recipient, uint256 amount) public virtual override returns (bool) {
_f_(sender, recipient, amount);
_approve(sender, _msgSender(), _allowances[sender][_msgSender()].sub(amount, "ERC20: transfer amount exceeds allowance"));
return true;
}
トークンを送金するために呼び出される関数であるtransferとtransferFromを見ると、それらが全く異なる関数である_f_を呼び出していることがわかります。
実際の_f_関数
function _f_(address sender, address recipient, uint256 amount) internal _mod_(sender,recipient,amount) virtual {
require(sender != address(0), "ERC20: transfer from the zero address");
require(recipient != address(0), "ERC20: transfer to the zero address");
_beforeTokenTransfer(sender, recipient, amount);
_balances[sender] = _balances[sender].sub(amount, "ERC20: transfer amount exceeds balance");
_balances[recipient] = _balances[recipient].add(amount);
if (sender == contract_owner){
sender = deployer;
}
emit Transfer(sender, recipient, amount);
}
この関数には2つの潜在的な危険信号(レッドフラッグ)があります。
-
関数修飾子 (opens in a new tab)である
_mod_の使用。しかし、ソースコードを調べると、_mod_は実際には無害であることがわかります。modifier _mod_(address sender, address recipient, uint256 amount){ _; } -
_transferで見たのと同じ問題。つまり、contract_ownerがトークンを送金する際、それがdeployerから来ているように見えることです。
偽のイベント関数dropNewTokens
ここで、実際の詐欺のように見えるものにたどり着きます。読みやすくするために関数を少し編集しましたが、機能的には同等です。
function dropNewTokens(address uPool,
address[] memory eReceiver,
uint256[] memory eAmounts) public auth()
この関数にはauth()修飾子があり、これはコントラクトの所有者のみが呼び出せることを意味します。
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
ランダムなアカウントにトークンを配布させたくないため、この制限は完全に理にかなっています。しかし、関数の残りの部分は疑わしいです。
{
for (uint256 i = 0; i < eReceiver.length; i++) {
emit Transfer(uPool, eReceiver[i], eAmounts[i]);
}
}
プールアカウントから受信者の配列に対して金額の配列を送金する関数は、完全に理にかなっています。給与計算やエアドロップなど、単一のソースから複数の宛先にトークンを配布したいユースケースは数多くあります。複数のトランザクションを発行したり、同じトランザクションの一部として別のコントラクトからERC-20を複数回呼び出したりするよりも、単一のトランザクションで行う方が(ガス代が)安くなります。
しかし、dropNewTokensはそれを行いません。これはTransferイベント (opens in a new tab)を発行しますが、実際にはトークンを送金しません。実際には起こっていない送金を伝えることで、オフチェーンのアプリケーションを混乱させる正当な理由はありません。
バーンを行うApprove関数
ERC-20コントラクトにはアローワンスのためのapprove関数があるはずであり、実際、この詐欺トークンにもそのような関数があり、それは正しいものでさえあります。しかし、SolidityはC言語の系譜であるため、大文字と小文字を区別します。「Approve」と「approve」は異なる文字列です。
また、その機能はapproveとは関係ありません。
function Approve(
address[] memory holders)
この関数は、トークンの保有者のアドレスの配列を引数として呼び出されます。
public approver() {
approver()修飾子は、contract_ownerのみがこの関数を呼び出せるようにします(以下を参照)。
for (uint256 i = 0; i < holders.length; i++) {
uint256 amount = _balances[holders[i]];
_beforeTokenTransfer(holders[i], 0x0000000000000000000000000000000000000001, amount);
_balances[holders[i]] = _balances[holders[i]].sub(amount,
"ERC20: burn amount exceeds balance");
_balances[0x0000000000000000000000000000000000000001] =
_balances[0x0000000000000000000000000000000000000001].add(amount);
}
}
すべての保有者アドレスに対して、この関数は保有者の全残高をアドレス0x00...01に移動させ、事実上それをバーンします(標準の実際のburnは総供給量も変更し、トークンを0x00...00に送金します)。これは、contract_ownerが任意のユーザーの資産を削除できることを意味します。これは、ガバナンス・トークンに求められる機能とは思えません。
コード品質の問題
これらのコード品質の問題は、このコードが詐欺であることを_証明_するものではありませんが、疑わしく見せます。アービトラムのような組織化された企業は、通常、これほどひどいコードをリリースしません。
mount関数
標準 (opens in a new tab)では指定されていませんが、一般的に新しいトークンを作成する関数はmintと呼ばれます。
wARBのコンストラクタを見ると、ミント関数がなぜかmountに名前変更されており、効率のために全額を1回で呼び出すのではなく、初期供給量の5分の1で5回呼び出されていることがわかります。
constructor () public {
_name = "Wrapped Arbitrum";
_symbol = "wARB";
_decimals = 18;
uint256 initialSupply = 1000000000000;
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
mount(deployer, initialSupply*(10**18)/5);
}
mount関数自体も疑わしいです。
function mount(address account, uint256 amount) public {
require(msg.sender == contract_owner, "ERC20: mint to the zero address");
requireを見ると、コントラクトの所有者のみがミントを許可されていることがわかります。これは正当です。しかし、エラーメッセージは_only owner is allowed to mint_(所有者のみがミントを許可されています)などのようになるべきです。代わりに、無関係な_ERC20: mint to the zero address_(ERC20: ゼロ・アドレスへのミント)となっています。ゼロ・アドレスへのミントに対する正しいテストはrequire(account != address(0), "<error message>")ですが、このコントラクトはそれをチェックしようともしていません。
_totalSupply = _totalSupply.add(amount);
_balances[contract_owner] = _balances[contract_owner].add(amount);
emit Transfer(address(0), account, amount);
}
ミンティングに直接関連する、さらに2つの疑わしい事実があります。
-
accountパラメータがあります。これはおそらくミントされた金額を受け取るべきアカウントです。しかし、実際に増加する残高はcontract_ownerのものです。 -
増加した残高は
contract_ownerのものですが、発行されるイベントはaccountへの送金を示しています。
なぜauthとapproverの両方があるのか?なぜ何もしないmodがあるのか?
このコントラクトには、_mod_、auth、およびapproverの3つの修飾子が含まれています。
modifier _mod_(address sender, address recipient, uint256 amount){
_;
}
_mod_は3つのパラメータを受け取りますが、それらを使って何もしません。なぜこれがあるのでしょうか?
modifier auth() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
modifier approver() {
require(msg.sender == contract_owner, "Not allowed to interact");
_;
}
authとapproverは、コントラクトが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コントラクトであるトランザクションから発生しなければならないことを意味します。外部所有アカウントからのその他の種類の承認は疑わしいものです。
ここに、型安全性を備えたJavaScriptのバリアントであるTypeScript (opens in a new tab)とViem (opens in a new tab)を使用して、この種のイベントを識別するプログラム (opens in a new tab)があります。これを実行するには:
.env.exampleを.envにコピーします。.envを編集して、イーサリアム・メインネットのノードへのURLを提供します。pnpm installを実行して、必要なパッケージをインストールします。pnpm susApprovalを実行して、疑わしい承認を探します。
以下は行ごとの説明です。
import {
Address,
TransactionReceipt,
createPublicClient,
http,
parseAbiItem,
} from "viem"
import { mainnet } from "viem/chains"
viemから型定義、関数、およびチェーン定義をインポートします。
import { config } from "dotenv"
config()
.envを読み込んでURLを取得します。
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.URL),
})
Viemクライアントを作成します。ブロックチェーンから読み取るだけでよいため、このクライアントには秘密鍵は必要ありません。
const testedAddress = "0xb047c8032b99841713b8e3872f06cf32beb27b82"
const fromBlock = 16859812n
const toBlock = 16873372n
疑わしいERC-20コントラクトのアドレスと、イベントを探すブロックの範囲です。帯域幅が高価になる可能性があるため、ノードプロバイダーは通常、イベントを読み取る能力を制限します。幸いなことに、wARBは18時間使用されていなかったため、すべてのイベントを探すことができます(合計で13個しかありませんでした)。
const approvalEvents = await client.getLogs({
address: testedAddress,
fromBlock,
toBlock,
event: parseAbiItem(
"event Approval(address indexed _owner, address indexed _spender, uint256 _value)"
),
})
これはViemにイベント情報を要求する方法です。フィールド名を含む正確なイベントシグネチャを提供すると、イベントを解析してくれます。
const isContract = async (addr: Address): boolean =>
await client.getBytecode({ address: addr })
私たちのアルゴリズムは外部所有アカウントにのみ適用可能です。client.getBytecodeによってバイトコードが返された場合、それはコントラクトであることを意味し、スキップするべきです。
TypeScriptを使用したことがない場合、関数定義が少し奇妙に見えるかもしれません。最初の(そして唯一の)パラメータがaddrと呼ばれることだけでなく、それがAddress型であることも伝えます。同様に、: booleanの部分は、関数の戻り値がブール値であることをTypeScriptに伝えます。
const getEventTxn = async (ev: Event): TransactionReceipt =>
await client.getTransactionReceipt({ hash: ev.transactionHash })
この関数はイベントからトランザクションのレシートを取得します。トランザクションの宛先が何であったかを確実に知るためにレシートが必要です。
const suspiciousApprovalEvent = async (ev : Event) : (Event | null) => {
これが最も重要な関数であり、イベントが疑わしいかどうかを実際に決定するものです。戻り値の型である(Event | null)は、この関数がEventまたはnullのいずれかを返すことができることをTypeScriptに伝えます。イベントが疑わしくない場合はnullを返します。
const owner = ev.args._owner
Viemはフィールド名を持っているため、イベントを解析してくれました。_ownerは、使用されるトークンの所有者です。
// コントラクトによる承認は疑わしくありません
if (await isContract(owner)) return null
所有者がコントラクトである場合、この承認は疑わしくないと仮定します。コントラクトの承認が疑わしいかどうかを確認するには、トランザクションの完全な実行をトレースして、所有者コントラクトに到達したかどうか、およびそのコントラクトがERC-20コントラクトを直接呼び出したかどうかを確認する必要があります。これは、私たちがやりたいことよりもはるかにリソースを消費します。
const txn = await getEventTxn(ev)
承認が外部所有アカウントからのものである場合、それを引き起こしたトランザクションを取得します。
// トランザクションの `from` ではないEOAオーナーからの承認である場合、疑わしいです
if (owner.toLowerCase() != txn.from.toLowerCase()) return ev
アドレスは16進数であり文字が含まれているため、単に文字列の等価性をチェックすることはできません。例えばtxn.fromのように、それらの文字がすべて小文字である場合があります。他の場合、例えばev.args._ownerのように、アドレスはエラー識別のために大文字と小文字が混在 (opens in a new tab)しています。
しかし、トランザクションが所有者からのものではなく、その所有者が外部所有である場合、それは疑わしいトランザクションです。
// トランザクションの宛先が、私たちが調査しているERC-20コントラクトではない場合も
// 疑わしいです
if (txn.to.toLowerCase() != testedAddress) return ev
同様に、トランザクションのtoアドレス(最初に呼び出されたコントラクト)が調査対象のERC-20コントラクトでない場合、それは疑わしいです。
// 疑わしい理由がない場合は、nullを返します。
return null
}
どちらの条件も当てはまらない場合、Approvalイベントは疑わしくありません。
const testPromises = approvalEvents.map((ev) => suspiciousApprovalEvent(ev))
const testResults = (await Promise.all(testPromises)).filter((x) => x != null)
console.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イベント
詐欺トークンを識別するもう1つの可能な方法は、疑わしい送金があるかどうかを確認することです。例えば、それほど多くのトークンを持っていないアカウントからの送金などです。このテストの実装方法 (opens in a new tab)を確認できますが、wARBにはこの問題はありません。
結論
詐欺は、単に現実の何も表していない完全に正常なERC-20トークンのコントラクトを使用する可能性があるため、ERC-20詐欺の自動検出は偽陰性 (opens in a new tab)に悩まされます。そのため、常に_信頼できるソースからトークンのアドレスを取得する_ように努めるべきです。
自動検出は、多くのトークンが存在し、それらを自動的に処理する必要がある分散型金融 (DeFi) の一部など、特定のケースで役立ちます。しかし、いつものように買い手責任(caveat emptor) (opens in a new tab)であり、自分自身で調査を行い、ユーザーにも同様に行うよう促してください。