0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Binanceホットウォレット流出をEtherscan APIで読み解く — EIP-7702 SetCodeトランザクションの構造と検知

0
Last updated at Posted at 2026-10-30

2026年10月29日20時24分(UTC)、Binanceのホットウォレット「Binance 14」から167,464ETHと64種類のトークンが、たった1本のトランザクションで外部アドレスへ移りました。続けて「Binance 16」からも28,005.6ETHが流出しています。被害総額は報道によって25億ドルから55億ドルまで幅がありますが、本記事は数字の確定ではなく、公開台帳から何が読み取れるかに絞ります。

結論を先に書くと、この事件は「EIP-7702の脆弱性」ではありません。EIP-7702の権限委任(authorization)はそのウォレット自身の秘密鍵でしか署名できないため、委任が成立した時点で署名鍵が攻撃者の手にあったことが強く示唆されます。EIP-7702は、鍵を握った攻撃者が65資産を1トランザクションで原子的に抜くための道具として使われました。

本記事ではEtherscan APIだけを使って、(1)SetCodeトランザクションの読み方、(2)攻撃の段取り、(3)発行体による凍結までの時間、(4)防御側が実装できる検知、を順に見ていきます。アドレスとトランザクションハッシュはすべて本文に載せているので、手元で再現できます。

1. EIP-7702のおさらい(3点だけ)

EIP-7702(Pectraで有効化)は、EOA(通常のウォレット)にコントラクトのコードを「委任」する仕組みです。

  1. トランザクションタイプ0x4(SetCode)にauthorizationListを同梱する。各要素は(chainId, address, nonce, yParity, r, s)で、addressが委任先コントラクト。
  2. authorizationはEOA自身の鍵で署名し、そのEOAの現在のnonceと一致していなければ無効。
  3. 成立するとEOAのcodeが0xef0100 || 委任先アドレス(23バイト)になり、そのEOA宛ての呼び出しは委任先のコードで実行される。委任先がETHやトークンを送る処理を持っていれば、呼び出し1回で全資産を動かせる。

3点目が今回の要です。委任先のコードはEOAの文脈で動くので、送金はEOA本人の送金として台帳に記録されます。ただし通常トランザクションではなく**内部トランザクション(internal tx)**として現れます。ここが調査の最初の分岐点です。

2. まず現状を見る: eth_getCode

import requests
B = "https://api.etherscan.io/v2/api?chainid=1"
KEY = "..."  # 無料キーで足ります

def get(**p):
    p["apikey"] = KEY
    return requests.get(B, params=p, timeout=30).json()["result"]

code = get(module="proxy", action="eth_getCode",
           address="0x28C6c06298d514Db089934071355E5743bf21d60", tag="latest")
print(code)  # -> "0x"

10月30日午前(UTC)時点でBinance 14のcodeは0x、つまり委任はすでに解除済みです。EIP-7702では委任先に0x0000…0000を指定したauthorizationで委任を外せます。「今どうなっているか」はこの1コールで分かりますが、「何が起きたか」は履歴を見る必要があります。

3. 流出トランザクションを見つける: internal txから遡る

受領側アドレス0x5227f603c1ee34ce3c06efe6477003bdfccb2f8cの内部トランザクションを古い順に引きます。

rows = get(module="account", action="txlistinternal",
           address="0x5227f603c1ee34ce3c06efe6477003bdfccb2f8c",
           page=1, offset=10, sort="asc")
for r in rows:
    print(r["timeStamp"], r["from"][:12], int(r["value"]) / 1e18, r["hash"])
1793305463 0x28c6c06298 167464.25 0x53bd38b816c23e74134786c29a8c18d792777998a3b9326047b583100e88e2f3
1793307863 0x28c6c06298   1699.92 0x31c4c9fe287d...
1793310251 0x28c6c06298    369.83 0x55ba4dabc41b...

167,464ETHがBinance 14から内部トランザクションで来ています。通常のtxlistにはこの送金は載りません。親トランザクション0x53bd…e2f3をeth_getTransactionByHashで見ると:

項目 値
type 0x2(通常のEIP-1559)
from 0x7c8601d4645b93cea45f71e118a729332e6b5ca0(攻撃者のオペレータ)
to 0x28c6c062…1d60(Binance 14本人)
value 0
input 0x8a179be4 + 引数(4,362文字)

EOAであるBinance 14宛てに、関数呼び出しのcalldataが付いたトランザクションが送られています。EOAは本来calldataを解釈しませんが、委任済みなら委任先コードが実行されます。これが「委任コード経由のスイープ」の痕跡です。

calldataをデコードする

0x8a179be4は公開ABIに無いセレクタですが、引数の並びから(address recipient, address[] tokens)と読めます。

tx = get(module="proxy", action="eth_getTransactionByHash",
         txhash="0x53bd38b816c23e74134786c29a8c18d792777998a3b9326047b583100e88e2f3")
inp = tx["input"][2:]
words = [inp[8 + i*64 : 8 + (i+1)*64] for i in range((len(inp) - 8) // 64)]
recipient = "0x" + words[0][-40:]
offset = int(words[1], 16) // 32
n = int(words[offset], 16)
tokens = ["0x" + w[-40:] for w in words[offset+1 : offset+1+n]]
print(recipient, n, tokens[0])
# 0x5227f603c1ee34ce3c06efe6477003bdfccb2f8c 65 0x0000000000000000000000000000000000000000

配列長は65。先頭のゼロアドレスはネイティブETHを意味する慣例で、残り64個がERC-20トークンです。つまり「ETH+64トークンを指定の宛先へ全額送れ」という1回の呼び出しで、Binance 14の残高が丸ごと動きました。

4. SetCodeトランザクションを読む: 50件のauthorization

オペレータ0x7c86…5ca0のトランザクション一覧を古い順に見ると、スイープの3分前にBinance 14宛てのtype 0x4トランザクションがあります。

tx = get(module="proxy", action="eth_getTransactionByHash",
         txhash="0xad99264d5532ae0dd04b2004eebda05f25657e70dacb764f7ac8d4ccd051298b")
print(tx["type"], len(tx["authorizationList"]))
for a in tx["authorizationList"][:3]:
    print(a["address"], int(a["nonce"], 16))
0x4 50
0x63e842b054bd624a465454d533c33f573e9c6fae 18915848
0x63e842b054bd624a465454d533c33f573e9c6fae 18915847
0x63e842b054bd624a465454d533c33f573e9c6fae 18915846

authorizationは50件、委任先はすべて同じ0x63e8…6fae、nonceは連番です。EIP-7702のauthorizationはnonceが一致しないと無効になるため、1秒に何本もトランザクションを出すホットウォレットでは「署名した時点のnonce」と「取り込まれた時点のnonce」がずれます。攻撃者は50個のnonceで署名を用意し、どれか1つが当たればよいようにしていました。取引所のホットウォレットを狙うことを前提にした作りです。

そして繰り返しになりますが、この50件の署名はBinance 14の秘密鍵でしか作れません。

5. 攻撃の段取り(オペレータの履歴から)

オペレータ0x7c86…5ca0の履歴は、準備から実行まで一直線です。

UTC 事象
10/28 07:39〜 Tornado Cashの0.1ETHプールから0.098ETH×3を受領(ガス資金)
10/29 02:28 委任先コントラクト①(1,256バイト・未検証)をデプロイ
13:51〜14:24 テスト用EOA2本にtype 0x4で①を委任し、0x8a179be4を呼ぶ予行演習
15:59 委任先コントラクト②0x63e8…6fae(1,674バイト)をデプロイ
16:20〜16:47 別のテストEOAで②の予行演習
18:20 1.08ETHの入金(本番のガス)
20:20:59 Binance 14宛てのtype 0x4(authorization 50件)
20:24:23 スイープ: 167,464ETH+64トークン→0x5227…2f8c
20:24:47 Binance 16から28,005.6ETH(こちらは通常のtype 0x2送金)
20:38:47 195,460ETHを0x8532…43d4へ集約

Binance 16の方は委任を使わない通常送金です。鍵を持っているなら委任は不要で、7702は「多数のトークンを一度に抜く」ために選ばれた道具だと分かります。

余談ですが、攻撃者が20:29にテスト送金した宛先0x9749292d…に対して、直後から0x9749…で始まる別アドレスから0ETHのトランザクションが大量に届いています。攻撃者の「次の送金先」を狙ったアドレスポイズニングです。盗んだ側も狙われる、という生態系の観察ができます。

6. 凍結までの時間: 発行体イベントをgetLogsで読む

USDTとUSDCの発行体コントラクトは、アドレスをブラックリストに入れるとイベントを出します。topic0はイベントシグネチャのkeccak256です。

トークン イベント topic0
USDT AddedBlackList(address) 0x42e160154868087d6bfdc0ca23d96a1c1cfa32f1b72ba9ba27b69b98a0d819dc
USDC Blacklisted(address) 0xffa4e6181777692565cf28528fc88fd1516ea86b56da075235fa575af6a4b855
rows = get(module="logs", action="getLogs",
           address="0xdac17f958d2ee523a2206206994597c13d831ec7",
           topic0="0x42e160154868087d6bfdc0ca23d96a1c1cfa32f1b72ba9ba27b69b98a0d819dc",
           fromBlock=26300350, toBlock="latest", page=1, offset=1000)
for r in rows:
    target = "0x" + r["data"][-40:]  # USDTの引数はindexedではないのでdataに入る
    print(int(r["timeStamp"], 16), target)

結果を時系列に並べると:

UTC(10/29) 発行体 対象
20:24:23 — スイープ実行
21:01:35 Tether 0x5227…2f8c(受領先本体)+テスト送金先2本ほか
21:04:59 Circle 0x5227…2f8c
22:06:23 Tether Binance 14本体
23:53:35 Circle Binance 14本体

スイープから37分でTether、40分でCircleが受領先を凍結しています。興味深いのは22時以降に被害者側のBinance 14そのものが凍結されている点で、委任が生きている間に再スイープされるのを防ぐ措置と読めます。

凍結前に逃げた分もtokentxから数えられます。受領先へのUSDT流入は1,875,464,713、凍結前の流出は700,000,000(500Mと200Mの2本)。ただしこの2本の宛先も21:01:35〜47に同時に凍結されています。USDCは流入823,000,000で、凍結前の流出はゼロでした。

7. 防御側で実装できる検知

ここまでの観察を、AML/セキュリティ監視の実装に落とすと4つになります。筆者が運用しているスクリーニング基盤にも同じ考え方で組み込みました。

(a) 自ウォレットの委任状態を監視する

eth_getCodeの結果が23バイトで0xef0100から始まれば委任済みです。取引所・発行体・カストディの運用ウォレットは、委任されていること自体が異常なので、定期的に見るだけで価値があります。委任先が検証済みコントラクトかどうかで重みを変えると誤報が減ります。

def classify(code_hex: str) -> tuple[str, str | None]:
    body = code_hex[2:].lower()
    if not body:
        return "eoa", None
    if len(body) == 46 and body.startswith("ef0100"):
        return "eip7702", "0x" + body[-40:]
    return "contract", None

(b) スイープ痕を見る

委任済み、かつ「同一宛先への内部トランザクションによる大口ETH送金」と「同一ブロック近傍での多数のERC-20送出」が重なったら、ほぼスイープです。通常トランザクションしか見ていない監視はこれを見落とします。内部トランザクションを必ず取るのが実装上のポイントです。

(c) 発行体の凍結イベントを台帳化する

USDT/USDCのブラックリストイベントは、「発行体が不正と判断したアドレス」の一次情報です。topic0ごとにgetLogsを回して台帳にしておけば、スキャン時に「このアドレスは何時何分に誰が凍結したか」を即答できます。凍結の解除イベント(RemovedBlackList/UnBlacklisted)も拾わないと、解除済みのアドレスを永久に危険扱いしてしまうので注意が必要です。

(d) 盗難資金の到達先をラベルにする

攻撃者アドレスからの送金先を2ホップ程度まで有界に追い、受領アドレスを「盗難資金の受領」としてラベル化しておくと、取引所の入金審査で「この入金は10/29の流出資金に触れている」と出せます。DEXルータ・ブリッジ・既知の取引所ホットウォレットは除外し、取引所に到達した事実は別の情報(「どの取引所に着いたか」)として扱うのが実務的です。ホップを深くしすぎると無関係な第三者を巻き込むので、上限としきい値は必須です。

まとめ

  • Binance 14の流出は、type 0x4のSetCodeトランザクション(authorization 50件・連番nonce) →委任コード経由の1回のスイープ(ETH+64トークン) という構造だった。
  • authorizationはウォレット自身の鍵でしか署名できないため、7702は原因ではなく手段。
  • 発行体の凍結はスイープから37〜40分。凍結前に動いた分も宛先ごと凍結された。
  • 防御側は「委任状態の監視」「内部トランザクションを含むスイープ痕」「凍結イベント台帳」「盗難資金のラベル伝播」の4点で、この種の事件に当日中に反応できる。

本文のハッシュとアドレスはすべてEtherscanで確認できます。数字が報道と違う場合は、オンチェーンの方を信じてください。

関連: ChainAnalyzerのニュース記事「Binanceホットウォレット流出に対応: EIP-7702委任検知・凍結台帳・盗難資金ラベル」

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?