本文は、小紅書 Android 9.43.1(build 9431801)におけるリクエスト署名のリバースエンジニアリング結果を記録したもので、deviceId、MUA、SIG、S1、Shield、main_hmac、SSK、登録リクエスト、デバイスプロファイルを扱う。MUAはX25519、AES-CBC、zlib、JSONを使用し、SIGはcanonical化したSHA-256と固定のGF(2)アフィン変換に基づく。S1はダイジェスト、独自のAES風CBC、タイムスタンプ、CRCを使用する。Shieldは独自のHMAC-MD5、RC4、任意のSSK証明を用い、SSKはX25519とAES-256-GCMで復号される。さらに、セッション状態、カウンター、乱数素材、デバイス収集フィールドの関係を説明し、検証用サンプルと実装の詳細を示す。結論は指定されたバージョンと環境にのみ適用され、後続バージョン、実際のOEM収集、サーバー側のリスク管理方針を示すものではない。
本稿では、小紅書 Android 9.43.1(build 9431801)のリクエスト署名を分析する。対象はデバイス識別子、MUA、SIG、S1、Shield、および Shield に必要な鍵交換だ。本文中の定数とバイナリレイアウトは、すべてこのバージョンを対象としている。
MUA、SIG、S1 は libtiny の処理チェーンに対応し、Shield と main_hmac の復号は libxyass に対応する。以下では、入力の組み立て、暗号変換、出力形式の順に説明する。本文でいう native はこのバージョンのネイティブライブラリを指し、エミュレーターでの比較結果は、所定の環境入力に対してライブラリが出力した値にすぎない。
以下では ||でバイト列を連結し、XORはビット単位の排他的論理和を表す。u32le、u32beはそれぞれ 32 ビット整数のリトルエンディアンおよびビッグエンディアン表現を表す。整数演算での切り捨てがある場合は、個別に明記する。
本稿はクライアントの署名実装をリバースエンジニアリングした記録であり、対象範囲は Android 9.43.1(build 9431801)のローカルアルゴリズムとメッセージ形式に限られる。ここで示す数式と定数は、このバージョンがリクエストヘッダーをどのように組み立てるかを説明するものであり、無許可アクセス、リスク制御の回避、または大規模なスクレイピングの手法を構成するものではない。サーバー側の方針、デバイス環境、後続バージョンによって、本文の結論は変わり得る。対象外の収集分岐や実機の状態を、たった一度の成功レスポンスで補って判断することもしない。
0. 署名フローの全体像
コンテンツリクエストには、次のフィールドが関係する。
フィールド
内容と用途
x-mini-mua
セッション公開鍵、ランダム素材、カウンター、暗号化されたデバイスプロファイル
x-mini-sig
リクエストの canonical 文字列の SHA-256 ダイジェストと、その前半に対するアフィン変換
x-mini-s1
canonical 文字列、カウンター、タイムスタンプから生成される 62 バイトの構造体
shield
リクエストの HMAC、およびオプションの SSK 関連証明
x-mini-gid
サーバー登録時に返される識別子
SIG と S1 は同じ canonical 文字列を使う。以下ではこれを canonical と呼ぶ。
ここで METHOD は大文字、PATH はドメイン名を含めず、QUERY は疑問符の後ろにある、再デコードもソートもしていない元の文字列で、疑問符自体は含めない。body は実際に送信するバイト列、SHA-256 は 64 文字の小文字 hex、MUA は末尾のピリオドを含めて完全な形式を保持する。五つの項目の間にはそれぞれ LF バイト(0x0A)を一つ入れ、末尾には改行を追加しない。上式の \n\n は改行を表し、バックスラッシュと英字 n の組ではない。query が空の場合も、対応する空行を残す。
GET リクエストの body は空で、その SHA-256 は次のとおり。
Shield では別の入力を使う。path、query、指定されたリクエストヘッダー、body を固定順序でそのまま連結する。SIG、S1、MUA の三つのヘッダーを HMAC の入力データに直接含めることはない。
検証済みのコールドスタート時のクライアントフローは次のとおり。
これはクライアントが呼び出す順序であり、すべての手順が暗号学的に依存しているわけではない。たとえば登録リクエストでは Shield を使わず、key64 にも依存しない。各段階で使われるフィールドは次のとおり。
段階
MUA / SIG / S1
Shield
レスポンスの素材
取得 vfc_code
現在のブートストラップパスには
最初の呼び出しでは main_hmac のない 70B 構造体
xy-ter-str
register/android
登録 body から計算
なし
data.g
activate
フォーム body から計算
main_hmac あり、SSK なし、70B
SSK の暗号文、session
cold_start_config
最終リクエストから計算
main_hmac と SSK の両方が存在、118B
id_token
コンテンツリクエスト
最終リクエストから計算
通常は 118B
業務データ
activate のレスポンスには token が含まれることもある。ここで説明するクライアントは、すでに token を持っていても、cold_start_config をリクエストして更新する。sid と id_token はxy-common-paramsを経由して Shield に渡される。これらは canonical の独立したフィールドではない。
各レスポンスが更新する状態も異なる。登録ではdata.g、activate では main_ssk を復号し、data.sessionにsession.プレフィックスを付けて sid として保存する。cold_start_config ではdata.user_info.user_tags.id_tokenから token を読み取る。対応するレスポンスの処理が成功して初めて、後続のリクエストでこれらの新しい値を使える。
同じリクエストヘッダーと署名は、推薦、検索、著者ホームの各インターフェースにも使われる。匿名セッションで一連の処理を最後まで通過することを確認したインターフェースには、edith.xiaohongshu.com の imagefeed、user/info、user/posted、rec.xiaohongshu.com の homefeed、so.xiaohongshu.com の search/notes がある。canonical に含めるのは PATH だけで、ドメイン名は含めない。ドメインをまたぐ場合も変わるのは host と query だけで、署名式は変わらない。
サーバー側では、セッション単位の失敗が二種類確認されている。code=-100 では msg が「アカウントにログインしてください」、code=300011 では msg が「現在のアカウントに異常があります。アカウントを切り替えてから再試行してください」となる。どちらも連続したリクエストの途中で発生しており、同じデバイスで同じリクエストを再試行しても通常は失敗したが、別のデバイス識別情報で改めて初期化すると復旧した。これらはサーバー側のセッション状態とリスク制御状態を示すものであり、本文では特定の署名入力の誤りが原因だとは断定しない。
1. android_id と deviceId
deviceId には、標準的な MD5 と UUID v3 のバージョンビットおよびバリアントビットの規則が使われる。入力は android_id テキストの UTF-8 バイト列である。たとえば 16 文字の hex テキストも 16 バイトの ASCII として扱い、先に 8 バイトへ変換することはない。
ここでは name bytes をそのままハッシュし、namespace を追加で連結しない。そのため、任意の namespace を使う UUID v3 API で置き換えることはできない。
同値な書き方は (h[6] & 0x3F) | 0x30 と (h[8] & 0xBF) | 0x80である。前者では保持していた bit 4、5 が後でいずれも 1 に設定され、後者では保持していた bit 7 も後で 1 に設定されるため、最終結果は同じになる。
例:
例:
2. x-mini-mua:セッション素材とデバイスプロファイル
MUA の形式は次のとおり。
連結時に空白は入れない。二つの Base64URL セクションから末尾の=を取り除き、最後のピリオドは残す。Part1 はそのまま JSON にデコードできるが、Part2 の復号にはまずセッション共有秘密を得る必要がある。
セッションの初期化では、まず 64 バイトのランダム素材sを取得し、続いて 32 バイトの X25519 生 scalar を取得する。セッション中はこの二つの素材を再利用する。リクエストカウンターやデバイスの実行状態は変化し続けるため、セッション素材を再利用しても、MUA 全体を再利用できるとは限らない。
2.1 X25519 と AES 素材
X25519 は素数体p = 2^255 - 19上で計算する。scalar を曲線演算に渡す前に clamp を行う。
元の scalar はそのまま保存してよく、clamp が作用するのは曲線演算に使う入力のコピーだけである。
基点 9 はリトルエンディアンの 32 バイト、つまり09の後に00を 31 個続けた形でエンコードする。MUA のサーバー公開鍵は次のとおり。
SSK では別のサーバー公開鍵を使う。第 7 節を参照。共有秘密がすべてゼロになった場合は、失敗として扱う必要がある。
この関係を特定できる根拠は、同じ native セッションから得た元の scalar、公開鍵k、AES 素材をそれぞれ照合したことにある。基点との乗算からkが得られ、固定の peer key と X25519 を行うと slotA が得られる。scalar の異なる二つのセッションでもこの関係が成立し、さらに導出した key/IV で Part2 を再構成できたため、直前の中間値だけに依存したものではないことが確認できる。
native では、フィールド要素を五つの 51 ビット limb で表現する。シリアライズ結果は標準 X25519 の 32 バイト・リトルエンディアン出力に対応するため、プロトコルを再現するだけなら内部の limb 表現を保持する必要はない。
native の中間バッファーを直接照合する場合、五つの limb はそれぞれリトルエンディアンの u64 を一つ占め、合計 40 バイトになる。その値を h0…h4 とすると、シリアライズ式は次のとおり。
ここでは完全な値に対して剰余を取り、各 limb を先に 51 ビットへ切り詰めることはしない。中間 limb には、まだ正規化されていない繰り上がりが含まれる場合がある。k と slotA も、この境界では同じ規則に従う。
曲線部分の最小実装:
セッション素材と slotA の分割:
2.2 Part1 のフィールド
現在のリクエスト生成で使われるキーの順序は次のとおり。
フィールド
意味
a
固定文字列 ECFAAF01
c
今回の MUA における整数カウンター
k
X25519 公開鍵、64 文字の小文字 hex
p
固定文字列 a
s
64 バイトのランダム素材、128 文字の小文字 hex
t
ランタイム状態オブジェクト。たとえば{"c":0,"d":0,"f":0,"s":4098,"t":0,"tt":[]}で、内部の t にはミリ秒単位の uptime を渡せる。
u
登録以外のリクエストで OAID が空でない場合は、"00000000" + oaid
v
固定文字列 2.9.99
シリアライズにはコンパクト JSON を使い、Unicode は ASCII エスケープに変換せず、フィールド順も保持する。登録経路では u を省略する。過去の完全なリプレイサンプルには t を含まない Part1 もあるため、リプレイ時は元のフィールド集合を維持しなければならない。キーを追加すると Part1 と MUA 全体が変わる。
同じ登録処理で使われる body.c、MUA.c、S1 フレーム内の counter には、すべて同じ値が入る。コールドスタート時のクライアントが最初に登録するときは c=2 だが、カウンターはセッション状態で管理しなければならず、すべてのリクエストで 2 を固定値として使ってはいけない。
Part1 のフィールド集合は、プラットフォームやバージョンによって変わる。iOS 9.47.2 で確認された Part1 ではa=ECFAAF02が使われ、さらにi、m(ミリ秒単位のタイムスタンプ)、nなど、本文で扱っていないフィールドも現れる。リクエストヘッダーにはさらにx-mini-nsigが付く。本文の式をバージョンをまたいで再利用する前に、まずフィールド集合を照合する必要がある。
Part1 の組み立て:
dict は挿入順に出力され、上表のキー順と正確に一致する。コンテンツリクエストでは OAID が空でない場合、u('00000000' + oaidに位置し、tとvの間にある)、登録経路ではuを省略する。
2.3 Part2 のエンコードと暗号化
Part2の平文はデバイスプロファイルのJSONで、確認できたベースラインには95個のフィールドが含まれている。パッケージのバージョン、ビルド情報、インストール時刻、バッテリー、ネットワーク、ストレージ、プロセス状態などを扱う。
キーの順序は x0,x1,x10,…で、数値順ではない。ここで並べ替えるのはトップレベルのキーだけで、ネストしたオブジェクトは元の順序のままシリアライズする。浮動小数点数の表記やUnicodeのエスケープ方法も入力バイト列の一部なので、言語をまたいで再現する場合は一致させる必要がある。
PKCS#7のパディング規則:
圧縮結果が16バイト境界に揃っていても、16個の 0x10 を追加する。
逆向きに検証する場合は、Base64URLデコード、CBC復号、PKCS#7の検証と除去、zlib展開の順に処理する。圧縮データにはzlibラッパーが付いているため、raw DEFLATEとして直接展開することはできない。同じJSONでも、圧縮器が異なれば圧縮後のバイト列が完全に一致するとは限らない。バイト単位で再現するには、圧縮ストリームも照合する必要がある。
フィールドの値はJSONの型を保持しなければならない。たとえば、x2はバージョン番号の整数、x17はSDKの整数、x43はネットワークを表す文字列、x242は空の配列である。"0" と 0 も入れ替えられない。x5の "JTdCJTdE" はテキスト %7B%7D のBase64であり、 {} のBase64ではない。ここで扱うプロファイルと登録時のsnapshotは、互いに独立した2つのオブジェクトである。
この層の検証では、平文JSON、zlibバイト列、CBC暗号文、最終的なMUAを同時に比較する必要がある。「自分で暗号化したものを自分で復号できる」ことを確認するだけでは、nativeとの一致は証明できない。セッション用の固定材料と元のプロファイルを固定したところ、完全なMUAはnativeの出力とバイト単位で一致した。
暗号化の復元とデバイス情報の収集処理の復元は別の問題である。合成したプロファイルを使えばエンコード処理の流れは検証できるが、実際のAndroid端末ですべてのフィールドがどこから取得されるかまでは導けない。
外側の処理パイプライン:
sorted(profile) は文字列順のソートで、x0, x1, x10, … のトップレベルキーの順序がそのまま得られる。ネストしたオブジェクトは入力された順序を保つ。
2.4 95フィールドのプロファイル完全マッピング
次の表は、実装済みのプロファイル構築方法をまとめたものである。パッケージ、ビルド、ハードウェアのフィールドは端末パラメータから取得し、時刻や実行状態のフィールドは明示的に指定できる。固定値、時刻のオフセット、seedからの派生値はオフラインで合成するための方法であり、実機での収集式を意味しない。引用符付きの値はJSON文字列で、それ以外の数値は整数である。ただし、x247の値は浮動小数点数である。
実機で確認済み(Xiaomi MI 6 / Lineage 15 / 9.43.1):ログインプロセスの MUA Part2 の平文はちょうどこの95個のキーで、合成器のキー集合と一致した。パッケージ名、バージョン、board、product、ABI、build id/host/incremental はパラメータ表と一致している。同じ起動中に取得した2回分の95キー dump は、時間、明るさ、x258、x293、x302/x303 だけが異なっていた。確認できたものの合成時のデフォルト値を変更していない項目として、x70 は Wi-Fi の 3 ではなく 25、x258 は 1 になる場合がある、x235 は 3348494、x302/x303 は空でなく同じプロセス内でも変化する、という点がある。2社目のメーカーのサンプルはまだない。
Nを現在のUnix時刻(ミリ秒)、Iを保存されたインストール時刻、Uを max(1, uptime_ms)とする。uptimeが渡されなかった場合は、Nから保存された起動時刻を引く。実行状態について明示的に渡された値は、次の表のデフォルト値より優先される。固定デバイスパラメータは、同じプロファイル内で一貫させる必要がある。
フィールド
取得元またはデフォルト値
x0
パッケージ名 "com.xingin.xhs"
x1
バージョン "9.43.1"
x2
バージョン番号9431801
x3, x4
I
x5
"JTdCJTdE"
x6
launch_time_ms、デフォルトはN−1300
x7
hardware
x8
build_time_ms
x9
"{product}-{build_type} {android_release} {build_id} {build_incremental}"
x10
build_fingerprint
x11
build_host
x12
build_id
x13
build_tags
x14
build_type
x15
build_incremental
x16
android_release文字列、例: "15"
x17
sdk_int、例: 35
x18
security_patch
x19
board
x20
manufacturer
x21
supported_abis、カンマ区切りの文字列
x22
device
x23
brand
x24
model
x25
product
x26
machine
x27
kernel_release
x28
kernel_version
x29
baseband
x30
解像度と密度の文字列、例: "1080,1920,480"
x31
55
x32
1
x33, x34, x35, x36
バッテリーのstatus、scale、level、plugged。デフォルトは2、100、99、2
x37
0
x38
networkが "wifi" の場合は1、それ以外は0
x39
usb_config、デフォルトは "adb"
x40
5
x41, x42
SIMオペレーター名と数値コード。いずれも文字列
x43
network、デフォルトは "wifi"
x44
N
x45
brightness、デフォルトは21
x70
Wi-Fiなら3、それ以外は1
x72
Wi-Fi接続時刻。デフォルトはN−min(U,145000)、Wi-Fi以外では強制的に−1
x73
Wi-FiのDHCP時刻。デフォルトはN−min(U,55000)、Wi-Fi以外では強制的に−1
x78
保存されたUID
x79, x80
PID、TID。今回の実行状態で上書き可能
x87
max(0,N−1027)
x92
sdk_int
x93
3
x98、x120、x131
"0"
x146
seedから導出した28バイトの小文字hex(56文字)
x185
"IiGgSsKkCVvEeP"
x186、x187
−1
x194、x202、x203
"1"
x206、x207
0
x231、x232
total_storage(バイト)
x234
file_timesオブジェクト。デフォルト値は後述
x235
3275987
x236、x237
free_storage(バイト)
x238
locale_country文字列
x242
[]
x243
max(0,N−1063)
x247
下記の浮動小数点オブジェクト
x258、x259
0
x260
N
x261
1
x263、x264
0
x267
1
x269
保存された値。デフォルトは1230768000000
x272
0
x289
N+19
x290
1
x293
max(0,min(free_storage,available_memory))。available_memoryのデフォルト値は132146402
x296
""
x301
"LOADED,ABSENT"
x302、x303
""
x304
2
x305
−3
展開後の結合項目は合計95個のキーになる。構築時には既存のスロットを上書きできるが、キーを追加したり欠落させたりすると、この基準状態から外れる。x234のデフォルトのキー順は "1"、"2"、"3"で、値はそれぞれI−67648、I−70281、I−70435。x247は次のとおり:
2.5 seedの導出と永続化方針
デバイスseedは同じ合成IDを繰り返し生成するために使うもので、nativeの鍵導出入力ではなく、MUAのランダムなsやscalarの代わりにもならない。文字列seedはそのままUTF-8に変換し、bytes型のseedはまず小文字のhexテキストにしてからUTF-8に変換する。seedがない場合は、32バイトの乱数をhexテキストにして生成・保存する。
D(label,L)を次のバイト列の先頭Lバイトと定義する。カウンタjは0から始まる:
ここでいうjは導出ストリーム用のカウンタであり、MUA.cとは無関係である。永続フィールドは次の規則で初期化し、保存済みの値を優先する:
label
導出結果
install-age-days
R(label,7,120)。I = created_at_ms − その値×86400000
boot-age-minutes
R(label,25,4320)。boot_time_ms = created_at_ms − その値×60000
x146
hex(D(label,28))
process-id
R(label,12000,29999)
thread-delta
R(label,1,64)。TID = PID + その値
android-uid
R(label,10000,19999)
free-storage-percent
R(label,31,72)。free_storage = total_storage×その値÷100(整数除算)
created_at_ms、インストール時刻、起動時刻、x146はプロファイルとともに保存される。seedだけを残して毎回created_at_msをリセットすると、デフォルトのインストール時刻と起動時刻が変わる。x146自体はcreated_at_msに依存しない。
同じ導出規則は登録IDにも使われる:
label
結果または用途
android-id
hex(D(label,8))、16文字
oaid
hex(D(label,16))、32文字
soc-serial
hex(D(label,8))。SoC serialとして任意指定可能
boot-id
D(label,16)をUUID形式でそのまま表示する。バージョンビットは変更しない
apk-dir、apk-id
D(label,16)のパディングなしBase64URL。いずれも22文字
sdcard-ctime-sec
R(label,1600000000,1900000000)
sdcard-ctime-ns
R(label,0,999999999)
sdcard-ino
R(label,1,1000000)
sdcard-dev
R(label,1,65535)
sdcard-fsid-lo、sdcard-fsid-hi
それぞれD(label,4)をリトルエンディアンのu32として読み取る
合成インストールディレクトリは /data/app/~~{apk-dir}/com.xingin.xhs-{apk-id}。合成ファイルシステムの状態では第12項目だけを成功とし、fstypeには61267を設定し、残り11項目は両方とも失敗とする。SoC serialとboot_idはまとめて省略し、空のファイルツリーを基準に再現できる。MMC、UFS、および追加のSettingsにはデフォルトの仮想値を設定しない。
シミュレートしたJNI IDでは、完全なディスクリプタ java/util/Map->方法签名 のJava String.hashCodeを使う。UTF-16コードユニットごとに h=(31*h+unit) mod 2^32 を適用してから、第9.2節のMD5に入力する。シミュレートしたattestationでは SHA256(UTF8(deviceId)) を使い、IDが空の場合は空バイト列のSHA-256となる。これらの代替値で再現できるのはシミュレーション環境だけであり、ARTやTEEの実際の計算式ではない。
合成方針ではattestation分岐を残し、x148は空文字列、x128はファイル欠落を示すテキストとする。x170は、デバイスパラメータのmanufacturerが Xiaomi と等しいかどうかに基づいて、観測済みの分岐を選択する。実機で分岐を決めるのはクラスのロード結果と収集結果であり、この選択方針で実際の検出を代用することはできない。
3. x-mini-sig:SHA-256とGF(2)アフィン変換
出力は64文字の小文字hexである。後半16バイトはSHA-256ダイジェストをそのまま保持し、前半16バイトには固定のGF(2)アフィン変換を施す。
3.1 アフィン関係の求め方
GF(2)では、加算はXOR、乗算は単一ビット同士のANDである。各出力ビットは、128個の入力ビットの線形結合を取り、それに定数をXORしたものとして表せる。
ダイジェスト後の変換入口に入力を自由に注入できるなら、まず f(0) を求めれば、定数bが得られる。次に128個の単一ビット基底ベクトル e_j に対して評価する:
リクエスト単位の入出力しか取得できない場合は、各ダイジェスト前半xと署名前半yの組について、次を立てる:
GF(2)上のガウス消去で解く。拡大入力行列のランクが129に達して初めて全係数を一意に決定できる。さらに、学習サンプルとは別のデータで検証する必要がある。
これはアフィン変換を復元する方法である。現在の資料には最終行列と独立したキャプチャベクトルが残っているが、当時の全サンプル取得や消去過程を本稿で再現できるだけの元の求解記録はない。独立したGETサンプル11組によって、この行列の出力が一致することは確認できるが、この11組だけから128×129個の係数を逆算することはできない。
3.2 ビット順序と評価
行列を128行129列として表し、最後の列に定数項を格納する。
入力列0はビッグエンディアン整数の最下位ビットに対応する一方、出力行0は結果の最上位ビットに対応するため、向きが異なる。完全な行列のコンパクトな表現は付録に示す。
ある入力に対する中間結果は次のとおり。
POSTにも同じ式を使うが、異なるのはbodyのダイジェストだけだ。署名結果を比較する意味があるのは、canonicalが完全に一致している場合に限られる。
canonical、行列、ビット順を組み合わせると、次のようになる。
rows の第 j 列は入力文字列の bit j(3.2の列順を参照)、出力第 i bit は第 i バイト目の bit 7 - i % 8に書き込まれる。
4. x-mini-s1:ダイジェスト変換、CBC、二重CRC
S1はSIGの出力を使わない。canonicalの末尾にcounterを追加してSHA-256を再計算し、さらにタイムスタンプを加えて暗号化対象の構造体を作り、最後に標準Base64で出力する。
この処理系を特定する際、最初に観測されたのはBase64化前の62バイト構造だった。乱数を固定しても、プロセスをまたぐと結果が変わることがある。変化の一つはcanonical内でまだ固定されていないMUAに由来し、もう一つは後から書き込まれる秒単位のタイムスタンプに由来する。canonical、counter、時刻をすべて固定して初めて、残る差異がアルゴリズムによるものかを判断できる。
中間バッファへの書き込みをさらに追跡すると、結果は32バイトのdigest lane、44バイトのseed、48バイトの暗号文、二つのCRCに分解できる。canonical全体を固定した後も残る変化は、最終的には秒単位の値の返り値に対応しtime(NULL)。この秒単位の値を固定すると、プロセスをまたいでも出力は一致する。
laneは当初、固定XORアフィンモデルで表そうとしたが、新しいサンプルがモデルと矛盾した。その後、VM命令とメモリ書き込みを手掛かりに、4段の変換をそれぞれ復元した。CBC部分では、テーブル参照層をAES S-boxと入力・出力XORマスクに分解し、4.4節の明示的なラウンド関数を得た。ここでいうマスクはテーブルから復元した定数であり、K0から標準AESの鍵スケジュールを実行して得たラウンドキーではない。
4.1 SHA-256とワード再配置
各4バイトブロック内のバイト順は反転しない。
counterは符号なし32ビットとしてエンコードし、MUA.cと一致させる必要がある。counterはnestedのダイジェストにも使われ、最終フレームヘッダーにも現れる。フレームヘッダーだけを書き換えても、その新しいcounterに対応する完全なS1にはならない。
4.2 4段のlane変換
stateを8バイトずつ4段に分け、それぞれを処理してから連結する。
IEEE反映CRC-32を使用し、多項式は0xEDB88320、初期値は0xFFFFFFFF。
第1段では、まず各バイトの上位ニブルと下位ニブルを入れ替え、次に内部CRCの下位8ビットを共通のXORマスクとして使う。
第2段:
第3段ではニブルS-box(インデックス0..15)を使う。
CRCへの入力は展開後の16バイトだが、最後にXORするのは元の8バイトのままである。
第4段:
4段分の出力を連結してlane32とする。第1段のCRC更新は、バイト単位でnativeの中間状態と照合した。残りの段はそれぞれ独立した変換を使うため、第1段の式を32バイト全体に適用してはならない。
4.3 seedとフレーム構造
オフセット範囲
長さ
内容
[0,1)
1
marker 00
[1,5)
4
counter、リトルエンディアン
[5,6)
1
version 01
[6,54)
48
CBC暗号文
[54,58)
4
seed CRC、ビッグエンディアン
[58,62)
4
source CRC、ビッグエンディアン
2つ目のCRCは53バイトを対象とし、外側のmarkerとcounterは含めない。これも一時的なsourceバッファに連結する場合、そのバッファの長さは57バイトになる。ただし、これはCRCへの入力長とは別の概念である。
62バイトを標準Base64にすると84文字になり、末尾には「=」が1つ付く=。
timestamp_secondsはUnix時刻の秒数で、u32として書き込む。Part1.t.tのミリ秒単位のuptimeとは異なり、nestedのSHA-256には関与しない。時刻の変化はseed、CBC暗号文、後続の検証層に現れる。
4.4 融合型 AES 風 CBC
CBCのIVは次のとおり。
まず現在の平文ブロックとpreviousをXORする。previousの初期値はIVとする。ブロック変換にはAES標準のS-boxとMixColumnsを使うが、演算の並びとラウンドキーテーブルは次の形式で構成される。
Transposeは列優先の16バイト入力を行優先のstateに変換し、ShiftRowsは第r行をr個左ローテートする。ラウンドキーは上記の行優先stateに1バイトずつ対応するため、キーをさらに転置してはならない。
9ラウンドのキーは順に次のとおり。
K0はASCII文字列xhs0oh2iu4an7og2。最後の層はS-boxを1回適用するだけで、MixColumnsは行わない。
AES S-boxは、まずGF(2^8)で乗法逆元を求め、続いてアフィン変換を適用して構成する。既約多項式は0x11Bで、0の逆元は0として扱う。
MixColumnsでは、各列[a,b,c,d]に対して次を計算する。
44バイトのseedに4バイトのゼロバイトを補うと0x04ちょうど3ブロックになる。上記の変換は、初期キーを1つ使う標準AES-CBCでそのまま置き換えることはできない。
現在、固定時刻で counter=1 の native 完全フレームの照合が23組あり、固定時刻で counter=2 の完全フレームの照合が5組ある。後者は異なる5つのUnix秒値をカバーしており、counter=1のサンプルと同じ秒値、隣接する秒、1時間のずれ、さらに11 と 17000000001700000000 を含む。この5組の raw62 は、同じ canonical、counter=2、対応する秒値を使うと、式の結果と1バイト単位で一致する。51244a1951244a19 と 170e170e は、これらの照合で変化しない。
さらに、Android 9.47.0(build 9470809)の arm64 libtinylibtinyの内容は9.43.1のサンプルとバイト単位で一致した。したがって、9ラウンド鍵、FAFA、CC、51244a1951244a19 および 170e170e は、この2つのアプリバージョン間で変化していない。これらの定数は so 内に連続した平文として現れないため、変化の判定はリテラル検索ではなく、ライブラリ全体のイメージに基づいている。
前述の式を実行可能な検証に落とし込むため、以下にサードパーティーライブラリに依存しない最小実装を示す。SBOXはGF(2^8)の逆元から直接生成し、標準AES S-boxと一致する。
5. main_hmac:Shieldのkey64を取り出す
main_hmacはサーバーが返す暗号化素材であり、現在のリクエストのHMAC結果ではない。クライアントは/api/sns/v1/system_service/vfc_codeのレスポンスヘッダーxy-ter-strからBase64文字列を取得する。
固定ヘッダー:
復号結果は96バイトでなければならず、先頭16バイトはheaderと一致する。Shieldが使うのは中央の64バイトで、footerはリクエストHMACには関与しない。ここではPKCS#7のアンパディングを行わない。
96バイトはちょうどAESの6ブロック分だが、長さだけでAESだと証明することはできない。根拠には、ブロック変換における標準S-box、行シフト、列混合に加え、deviceIdを基にしたラウンドキー展開もある。標準AESとの差異は、別のカスタムS-boxではなく、以下のRconに集中している。
5.1 カスタムAES鍵展開
deviceIdには、ハイフン付き36バイトUUIDのASCII表現を使う。
続いてAES-128の鍵展開を実行するが、Rconは次の10個のビッグエンディアンu32に置き換える。
RotWordは4バイトを1バイト分左ローテートし、SubWordは各バイトに標準AES S-boxを適用する。合計44ワード、つまり176バイトのラウンドキーが得られる。
CBC復号では、P_i = D_round_keys(C_i) XOR C_(i-1)先頭ブロックの前ブロックには16個のゼロバイトを使う。平文スライス[0:16]、[16:80]、[80:96]から、それぞれheader、key64、footerを得る。
ブロック暗号のラウンド関数は標準AESを使う。初期AddRoundKey、9ラウンドのSubBytes、ShiftRows、MixColumns、AddRoundKeyを行い、最後のラウンドではMixColumnsを省略する。変更されているのはkey scheduleなので、標準AESライブラリを呼び出してkey16だけを渡すと異なる結果になる。
例:
この復元が正しいかを判定するには、deviceIdをまたいで平文のheaderとkey64を検証する必要がある。対応する素材18組では復号結果が一致している。単に暗号化と復号を往復させるだけでは、この照合の代わりにならない。
deviceId だけから key64 をオフラインで導くローカルな公式は存在しない。libxyass は xy-ter-str を受信したときだけ、それを prefs の s/main_hmac に書き込み、deviceId を AES のラッピング鍵として使って64バイトを復号する。so には f_key のリテラルはない。prefs が空のコールドスタートでは Shield は70バイトのままになる。通常のプロトコルで main_hmac を取得すれば、key64 を取り出せる。
5.2 逆方向の暗号化
同じdeviceId、既知のkey64、footerが与えられれば、main_hmacを逆算して再構成できる。
Eには5.1節のカスタムラウンドキーを使い、平文は6ブロックで固定して追加のパディングは行わない。Shieldがfooterを使わないからといって省略してはならず、footerも入力として保持する必要がある。逆方向の暗号化で示せるのはラッパーを再構成できることだけであり、deviceIdから未知のkey64を生成できることや、サーバーが配布するものと同じ新しい素材を自力で得られることを意味しない。
key64の取り出しも標準ライブラリだけで行える。SBOXは4.4節の例と同じで、復号には本節のラウンドキーを使った標準AESブロック関数を用いる。
6. Shield:リクエストHMACとSSK拡張
外側の形式は次のとおり。
ここではパディングを保持した標準Base64を使う。rawには2種類の長さがある。基本構造は70バイトで、main_hmacとmain_sskの両方がある場合は118バイトになる。
6.1 HMACの原文
各項目を次の順序で連結する。改行、疑問符、追加の区切り文字は入れない。
不足しているヘッダーは空文字列として扱い、最後にUTF-8でバイト列にする。host、scheme、METHODはこの原文には含めない。platform部分はバージョン定数とdeviceIdから再構成し、異なる可能性のあるxy-platform-info値を直接読み取らない。
bodyは実際に送信する内容と一致しなければならない。フォームリクエストでは、パーセントエンコード済みのフォームテキストを署名する。JSONでは、「意味が同じ」JSONを作り直すのではなく、最終的にシリアライズされたバイト列を署名する。
6.2 カスタムHMAC-MD5
HMACの外側の構造は変わらない。
key64はちょうど64バイトなので、XOR 0x36とXOR 0x5cはそれぞれ各バイトと同じ定数とのXORを表す。innerとouterはいずれも16バイトの生ダイジェストであり、outerを計算する際にinnerをhex文字列へ変換してはならない。
nativeの初期化と64ラウンドの圧縮は、依然としてMD5の構造を保っている。そのため、初期状態、ローテーションテーブル、加算定数、メッセージインデックス、レジスタ更新を項目ごとに比較できる。以下の変更を合わせたものがMD5_modである。
初期状態は標準MD5と逆順にする。
Tテーブル:
まずTをKにコピーし、次のように変更する。
ローテーションビット数は、まず標準MD5の4組をそれぞれ4回ずつ繰り返す。
その後、10か所を上書きする。
ラウンド関数とメッセージインデックス:
ラウンド39以降は、PINに従って入力レジスタを並べ替える。
1 ブロックの 64 バイトをリトルエンディアンで 16 個の u32 に分解し、M とする。
Padding は MD5 の規則に従い、追加 0x80、長さを 64 で割った余りが 56 になるまでゼロで埋め、その後、元のビット長をリトルエンディアンの u64 で書き込む。すべてのブロックを処理したら、state を 4 個のリトルエンディアン u32 として出力する。
この種の変種では、初期状態、テーブル定数、ラウンド関数、メッセージのインデックス、レジスタの更新順序をそれぞれ確認する必要がある。MD5 の T テーブルだけを置き換えれば済むわけではなく、ローテーション前の 32 ビット切り捨ても省略できない。
ダイジェストの確認値:
6.3 70 バイトの基本構造
定数は次のとおり。
RC4_KEY は 72 バイトの UTF-8 文字列である。標準的な RC4 の KSA と PRGA を使用する。
hf は main_hmac の有無に応じて EMPTY_HF または NONEMPTY_HF を選択するもので、HMAC を切り詰めた値ではない。基本構造の長さは 16 + 1 + 36 + 1 + 16 = 70 バイトである。
main_hmac がない場合、リクエスト HMAC は計算されない。このとき raw70 は deviceId のみに依存し、path、query、body を変更しても変わらない。main_hmac を取得して初めて、tail によってリクエスト内容が Shield に結び付けられる。70B と 118B はそれぞれ 96 文字と 160 文字の Base64 にエンコードされ、さらに XY を加えると全体の長さは 98 文字と 162 文字になる。
6.4 48 バイトの SSK 拡張
SSK の有無で出力を比較すると、基本部分は 70 バイトのままで、追加の末尾部分が 48 バイトあることが分かる。シリアライズ前の内容をさらに分解すると、2 つの TLV を識別できる。tag 4 は 20 バイトの証明、tag 5 は 24 バイトの証明である。
native は main_ssk の標準 Base64 ASCII を使用し、その 32 バイトの元データを直接使うわけではない。
長さバイト 14 と 18 は 16 進数で、それぞれ 20 と 24 を表す。
拡張には、RC4 キーストリームの指定範囲を使用する。
オフセットが 70 ではなく 68 なのは、最終的な raw の先頭 2 バイトが後処理で挿入されるためである。基本構造の同等な構築と拡張のストリームオフセットはそれぞれ維持する必要があり、plain54 と plain48 を単純に連結して RC4 をもう一度実行してはならない。
main_hmac が空の場合は、SSK があってもこの拡張分岐には入らない。query を変更すると request_hmac が変わり、それによって req_proof も変わる。SSK を変更した場合は、まず ssk_proof が変わる。
3種類のSSKと3種類のURLによる9組の固定 nonce サンプルでは、118バイト全体を再現できる。4バイトの nonce は libxyass の拡張分岐にある生成関数から得られ、getrandomgetrandom や /dev/urandom から取得したものではない。/dev/urandomこの関数はプロセス内のアトミックカウンタをインクリメントしてから、現在のスタック上にあるsp+0x20sp+0x20 というスロットのアドレス下位32ビットと XOR し、リトルエンディアンで書き出す:
続いて SHA1(ASCII(main_ssk_b64) || nonce4)SHA1(ASCII(main_ssk_b64) || nonce4)を tag 5 に格納する。sp+0x20sp+0x20 は実行時のスタック配置であり、プロトコル定数ではない。エミュレーターではスタックアドレスが安定しているため、連続するリクエストでは nonce の下位ビットがカウンタに応じて変化し、カウンタが16のべき乗をまたぐと単純なインクリメントではなく XOR による反転が現れる。実機ではASLRにより、この値はプロセスごとに異なる4バイト値になる。スタックアドレスが分からない移植実装でも、nonce はリクエストごとの入力として扱うべきであり、プロセスをまたいで共通の定数テーブルにしてはならない。同一プロセスで連続して取得した32組の native 出力は、上記の式を満たしている。
main_hmac のない 70B 形式はカスタム MD5 を必要とせず、単独で再現できる。
7. SSK:X25519 と AES-256-GCM
クライアントは /api/sns/v1/user/activate に一時的な X25519 公開鍵を送信し、フィールド名は client_public_key_base64 である。公開鍵はまず標準 Base64 に変換し、その後フォームの規則に従ってパーセントエンコードする。
SSK サーバー公開鍵は次のとおり。
デコード層は URL-safe Base64 にも対応できる。shared はそのまま 256 ビットの AES キーとして使われ、中間に HKDF はない。全ゼロの shared は拒否すべきであり、GCM タグは必ず検証し、平文は 32 バイトでなければならない。SSK の GCM nonce は 12 バイトで、Shield 拡張の nonce4 とは関係がない。
この処理を MUA の key/IV 分割と混同してはならない。MUA は共有秘密を AES-128-CBC の key と IV に分割するのに対し、SSK は共有秘密全体を AES-256-GCM に使い、IV はレスポンスから読み取る。
このリクエストとレスポンスでは、同じ一対の一時鍵を共有する必要がある。リクエストでは公開鍵を送信し、レスポンスを処理する際には対応する秘密鍵を保持する。秘密鍵を再生成して元のレスポンスを復号すると shared が変わり、GCM タグの検証に失敗する。MUA の scalar と activate の一時秘密鍵も、独立したセッション情報として管理すべきである。
RFC 7748 のベクトルで X25519 標準プリミティブを確認でき、GCM メッセージを組み立てれば復号とタグ拒否の挙動を確認できる。ただし、これらだけで、ある実際の activate レスポンスが同じ情報を使っていると証明することはできない。
main_ssk の復号:
shared 第2.1節の x25519()。peer は本節の SSK サーバー公開鍵であり、MUA の MUA_SERVER_PUBLIC_KEY ではない。得られた32バイトはそのまま AES-256-GCM の鍵になるため、第2.1節の slotA を再利用してはならない。
8. 登録リクエスト:GID と暗号化 snapshot
登録 API は POST /api/v1/register/android である。リクエストには MUA、SIG、S1 を付けるが、Shield は付けない。body は次のキー順で生成したコンパクトな JSON である。
a、c、k、p、s、v は同じ MUA Part1 と一致する。e は平文の identity オブジェクトで、初回起動時の empty-identity サンプルでは次のようになる。
d のエンコード処理は次のとおり。
d は MUA Part2 と同じセッションの slotA と外側のアルゴリズムを共有するが、平文は異なるデータ構造である。MUA Part2 の文字列をそのまま d に使うことはできない。
ある登録サンプルでは、snapshot の平文が 960 バイト、zlib の結果が 448 バイトで、パディング後の暗号文は 464 バイトだった。長さは圧縮後の内容によって決まり、プロトコルで固定された値ではない。
登録署名の依存関係は次のとおり。
署名の完了後に body を並べ替えたり変更したりすることはない。サーバーのレスポンスに含まれる data.g は、観測したサンプルでは 56 文字の hex であり、そのまま x-mini-gid と共通パラメータの gid に書き込まれる。
フィールド d と body の組み立て:
ここでの key と iv は、同じ登録処理の MUA Part2 と slotA を共有する。body の組み立てが完了した後、第 10 節の順序で SIG と S1 を計算する。
9. snapshot 内のハッシュとエンコード用フィールド
snapshot のフィールドには条件分岐があり、24 個のキーはあくまで基準にすぎない。たとえば他の条件が同じ場合、SDK 28 以降では x252 と x253 が追加される。オプションの収集結果によっては、さらにキーが追加または削除される。ここでは再現性に影響するハッシュとエンコードの関係だけを扱う。
9.1 x86:XXH3-128 と CRC32
XXH3 には標準の seeded 128-bit アルゴリズムを使用し、結果を high64 << 64 | low64 の形式に整形する。CRC32 は標準的な IEEE 反映形式で、常に 8 文字の hex で出力する。
入力は、正常に収集された内容を区切り文字なしで順番に連結したものである。
SoC/MMC/UFS および上記 Settings 値は、前後の ASCII 空白を取り除いたうえで先頭 200 バイトを切り出して正規化する。cpuinfo と boot_id はこの入力経路には含まれない。
この種のフィールドは、単一変数の差分で確認するのが適している。他の収集項目を固定して入力を 1 つだけ変更し、連結バッファーと最終ハッシュを同時に観察する。固定 seed の根拠には、出力への当てはめだけでなく、native における即値のロードも含めるべきである。XXH3 の短・中・長入力の各分岐には標準実装を使い、2 つの 64-bit hash を単純に連結して 128-bit の代わりにしてはならない。
x86 の組み合わせは 1 行だけで、xxh3_128 の完全な実装は付録 B に示す。
9.2 x137:JNI method ID の MD5
次の 7 個の JNI method ID を順番にリトルエンディアンの u32 としてエンコードし、28 バイトに連結して標準 MD5 を計算し、大文字の hex で出力する。
ハッシュの計算式と ID の出所は、分けて考える必要がある。
実機での経路は特定できている。native は java/util/Map を実行しFindClass、上表の順に各1回呼び出しGetMethodID、str x0 で64ビットの jmethodID をグローバルテーブルに書き込む。その後、MD5 は各スロットの先頭4バイトだけを読み取るため、リトルエンディアンの下位32ビットになる。実機サンプルのポインタはすべて4GB未満で上位32ビットが0だったため、trunc32 は完全なポインタと同じ値になる。ただし、すべての64ビットアドレス配置に一般化はできない。
これらの ID は ART のメソッドオブジェクトのアドレスで、約2.8 MiB のファイルバックエンドのない rw-rw- マッピングに収まっている。同じ起動中にプロセスを再起動しても7つの値は変わらないが、端末を再起動するとマッピングのベースアドレスがランダム化され、7つの値はまとめて移動し、相対オフセットは変わらない。したがって x137 は起動中だけ有効なランタイム変数であり、サーバーが固定テーブルで厳密に検証することはできない。シミュレーション環境では、ID の代わりに記述子の Java hashCode を使えるが、これはフォールバック用の oracle にすぎず、ART の割り当て公式ではない。
x137:
9.3 attestation と環境分岐
x147 は attestation バイト列を UTF-8 としてデコードし、不正なシーケンスを U+FFFD に置換する。x86 は元のバイト列を使用し、デコード後に再エンコードした x147 を使うことはできない。
入力元は特定できている。native は Class.forName をロードしcom.huawei.attestation.HwAttestationManager、リフレクションで呼び出す。このクラスは APK 内にはなく、Huawei のシステムクラスである。クラスが存在する場合、x148 は空文字列になり、元のバイト列が x86 に入る。クラスのロードに失敗すると x147 と x148 はどちらも現れず、attestation のバイト列も x86 には入らない。失敗後にはさらにgetDeviceID(I)[B と attestation_service を検出するが、これらで IHwTelephony$Stub のgetDeviceID戻り値を置き換えることはできない。
Xiaomi MI 6(Lineage、Huawei パッケージなし)では、プロセスを3回再起動してもすべて省略分岐になり、登録 snapshot の x147 経路からバイト列は生成されなかった。同じプロセス内でAMediaDrm_getPropertyByteArray("deviceUniqueId") は安定して32バイトを返すが、これは Widevine の取得値である。HwAttestationManager を隠しても x147 は引き続き消えるため、これは x147 の入力ではない。Huawei TEE のgetDeviceIDのペイロードは、依然として端末固有であり未調査である。
シミュレーション環境では、デフォルトで引き続き SHA256(UTF8(deviceId)) をフォールバックとして使う。元のバイト列を注入することも、明示的に省略分岐を選ぶこともできる。
同様に、OAID プロバイダー、ファイルの可読性、JNI 環境によっても snapshot は変わる。特定の固定環境におけるシリアライズと暗号化を再現できても、すべての端末メーカーの収集動作を再現したことにはならない。
9.4 x165:ファイルシステム情報の文字列エンコード
x165 は、12 個の固定された対象について、ファイルの状態とファイルシステムの状態をオブジェクトとしてエンコードする。成功した項目のキーは 1 から始まる 10 進数の序列表現で、/sdcard は "12" に対応する。各項目の形式は次のとおり。
固定の序号と対象は次のとおり。これらは Android の収集対象であり、記事読者のコンピューター上のディレクトリではない。
序号
収集対象
1
/data/system
2
/data/data/
3
/data/data/com.android.shell
4
/data/system/install_sessions
5
/data/data/com.google.android.webview
6
/data/data/com.google.android.gms
7
/dev/__properties__/u:object_r:radio_prop:s0
8
/dev/__properties__/u:object_r:ffs_prop:s0
9
/dev/__properties__/u:object_r:debuggerd_prop:s0
10
/dev/fd/1
11
/dev/fd/0
12
/sdcard
この MI 6 では、12個の対象のうち /data/system、/data/data/、/data/data/com.android.shell、/data/system/install_sessions、/sdcard、/dev/fd/0、/dev/fd/1、radio/debuggerd の2つの property ノードも存在する。一方、webview、GMS のデータディレクトリ、ffs_prop は存在しない。/system/bin/getprop と /system/bin/sh はどちらも存在し、9.5節の helper 規則に従えば、この端末の x128 は省略キーになる。データ消去後の register snapshot を使った直接照合はまだ行っていない。
ctime の秒とナノ秒の間に区切り文字はなく、ナノ秒は最低 9 桁で、不足分はゼロで補う。両者は符号付き 64 ビット整数として出力する。inode、device、fstype は符号なし 64 ビットとして出力する。fsid は 2 個の u32 をそれぞれ 8 文字の小文字 hex にエンコードし、low、high の順に連結する。
stat に失敗して statfs に成功した場合、ctime、inode、device はゼロにし、fsid と fstype は残す。stat に成功して statfs に失敗した場合、fsid はリテラル * となり、fstype は 0 になる。両方に失敗した場合は、その項目を省略する。すべての対象に失敗した場合、x165 の値は空オブジェクトではなく null になる。
たとえば第 12 項目だけを残し、stat の値がすべてゼロで、fsid の 2 つの語が 0xd3609fe8 と 0x04970d6b、fstype が 10 進数の 61267 である場合、次のようになる。
9.5 x170 と x128 の条件分岐
観測したクラスロード実験では、 com.android.id.impl.IdProviderImpl が存在すると x170 は "id_provider" になり、OAID は x173、x174、x175、x181 にも書き込まれ、x86 にも 1 回追加される。そのクラスを隠すと x170 は "vivo" に戻り、4 つの複製は消え、x86 に OAID は追加されなくなる。x171 には引き続き OAID が残る。これはこのエミュレーター環境での分岐結果であり、manufacturer 文字列だけから実際の端末でのプロバイダーを導き出すことはできない。
x128 は、ファイルを開くのに失敗した後の errno=2 のテキスト "No such file or directory" に対応する。観測した実験では、getprop と sh の 2 つのシステムヘルパーを同時に提供するとこのキーは消え、getprop だけを提供した場合は残る。boot_id は別途 x47 に入り、サンプルでは末尾の改行を削除した後、先頭 35 文字を残し、x86 には関与しない。異常な長さやエンコードに対する native の完全な読み取り動作は、まだ網羅できていない。
9.6 snapshot フィールドと出現条件
以下は、これまでに対応付けたフィールドである。文字列は UTF-8 でシリアライズし、容量フィールドはバイト数を表す整数とする。「存在」は、収集分岐が成功した場合、または呼び出し側から明示的に入力が与えられた場合を指す。
フィールド
値または出所
出現条件
x137
7つの Map method ID(trunc32)の MD5、大文字 hex
ベースライン
x165
ファイルシステムオブジェクトまたは null
ベースライン
x170
OAID プロバイダーのラベル
ベースライン
x171
OAID 文字列
ベースライン
x20
manufacturer
ベースライン
x21
CPU ABI リスト文字列
ベースライン
x25
product
ベースライン
x46
android_id
ベースライン
x49
ApplicationInfo.sourceDir
ベースライン
x77
boot hardware
ベースライン
x86
XXH3-128 と CRC32 を連結した40文字
ベースライン
x231, x232
総ストレージ容量(バイト数)
ベースライン
x236, x237
空きストレージ容量(バイト数)
ベースライン
x128
ファイル欠落エラーのテキスト
9.5節の失敗分岐
x147
attestation 元バイト列の UTF-8 replace 結果
attestation 分岐が存在する場合
x148
確認済みサンプルでは空文字列
attestation 分岐が存在する場合
x173, x174, x175, x181
OAID の4つのコピー
IdProviderImpl 分岐
x252, x253
ApplicationInfo.publicSourceDir
SDK≥28
x149
正規化した SoC serial
存在し、正規化後も空でない場合
x151
正規化した MMC cid
存在し、正規化後も空でない場合
x153
正規化した UFS id
存在し、正規化後も空でない場合
x47
末尾の CR/LF を除去した boot_id の先頭35文字
入力が存在する場合
x110
boot_id と同じ長さに切り詰めた kernel uuid
入力が存在する場合
x76
cpuinfo の差分サンプルにおける整数 0
該当する取得サンプルが存在する場合
SoC serial、MMC cid、UFS idはそれぞれ /sys/devices/soc0/serial_number、/sys/block/mmcblk0/device/cid、正規化したバイト列はx86に入り、フィールド値ではそのバイト列をUTF-8のreplacementでデコードする。不正なUTF-8入力では、両者を相互に置き換えることはできない。publicSourceDirが個別に提供されない場合、現在の再構成ではsourceDirを使用する。/sys/ufs/ufsid以下のSettings値は、空でない文字列として読み取れた場合に対応するスロットへ書き込まれる。まず前後のASCII空白を取り除き、200バイトに切り詰めてから、UTF-8のreplacementでデコードする。キーの有無は正規化前の非空条件で判定するため、空白だけの入力から空文字列フィールドが生成されることがある。
Settings名
フィールド
正規化したバイト列をx86に入れるか
gcbooster_uuid
x133
はい。android_idの後
uuid
x158
いいえ
pps_oaid
x169
はい。gcbooster_uuidの後
bluetooth_address
x63
いいえ
key_mqs_uuid
x132
いいえ
ad_aaid
x134
いいえ
mi_health_id
x157
いいえ
persist.sys.oppo.opmuuid
x159
いいえ
com.vivo.pushservice.client_id
x160
いいえ
iRoamingKey
x161
いいえ
com.vivo.pushservice.back_up
x162
いいえ
ZHVzY2Lk
x163
いいえ
この表は、現在マッピングされている分岐を網羅したものであり、まだ正常に収集できていない他のnative分岐を存在しないものとして扱ってはいない。MUA Part2にある同名のストレージおよびハードウェアスロットは別の端末プロファイルオブジェクトに由来するため、これを根拠に2つのJSONを統合することはできない。
10. 各層を1つのリクエストに組み立てる
セッションのブートストラップが完了すると、1回のリクエストは次の順序で処理される。
最終的なmethod、path、query、bodyを確定する。
ここでは、混同しやすい状態が3つある。MUAのセッション用ランダム素材、リクエストごとのcounter、そしてShieldのnonce4だ。これらはライフサイクルが異なり、互いに代用することもできない。
同じリクエストオブジェクトから、canonicalとShield sourceに必要なバイト列をそれぞれ取得できるようにする必要がある。HTTPライブラリが署名後にqueryの順序を変更したり、パーセントエンコーディングを変えたり、bodyを再シリアライズしたりすると、「ローカルの式は正しいのに、実際に送信した内容が一致しない」という問題が起こる。登録時は特に、最終的なbodyを先に生成してからSIGとS1を計算する必要がある。
10.1 SIG / S1を再計算できる入力例
以下は式を検証するための合成ベクトルで、MUAにはリテラル part1.part2.を使う。SIG/S1だけを切り分けるためのもので、サーバーに送信できる実際のMUAを示すものではない。
本文のLF連結規則に従ってcanonicalを組み立てると、結果は次のとおりだ。
このベクトルを2つの実装で照合し、ビット順、counter、時間単位、エンコーディングを確認する。固定時刻での counter=2 の native 完全フレームについては別途照合済みであり、第4.4節を参照。
現在ある固定入力の照合結果は、本文の MUA エンコード、SIG、S1、main_hmac の復号、Shield の計算式を支持している。計算式の照合とは別に、純粋な Python クライアントで匿名セッションの一連の処理も検証済みだ。登録で GID を取得し、main_hmac と main_ssk を復号したうえで、homefeed、imagefeed、search/notes、user/info、user/posted はすべて code=0 を返し、複数の合成端末とセッションにまたがって確認できた。この検証で確認できるのは、リクエストヘッダーの構築と状態の引き継ぎがサーバーに受け入れられることだ。x137 の実機入力は、起動中の ART アドレスであると特定されている(第9.2節参照)。一方、まだ対象外なのは、実際の TEE attestation バイト列と、OEM ごとの取得分岐全体である。これらは結論の適用範囲を限定するものであり、サーバーから一度成功応答が返っただけで補えるものではない。
付録:SIGのアフィン行列
各行の129ビットを「列0から列128」の順に17バイトの上位129ビットへ格納し、下位7ビットは0で埋める。以下のhexは合計2176バイト、128行分である。レイアウト上、各テキスト行には行列の4行を含めている。
デコード方法は次のとおり。
ここでのビットのパッキング順は、行列を格納するためだけに使われる。rowsをデコードした後は、第3.2節の入力列のビット順に従って計算する必要があり、パッキング時の上位ビット優先をdigestからビットを取り出す順序と取り違えてはならない。
付録B:XXH3-128の完全な分岐
x86では、xxHash 0.8.3のseed付きXXH3-128を使用する。ここで展開するのは標準ハッシュの実装であり、このプロトコル固有なのはseed 0x48FA5412、入力の連結順序、そして末尾にCRC32を追加する規則である。
実装ではseedを符号なし64ビットに正規化する。整数はリトルエンディアンで読み込み、64×64の乗算では完全な128ビットの結果を保持する。折りたたむ場合は、積の上位64ビットと下位64ビットをXORする。加算、乗算、負数化後の切り捨ては、コード中のMASKに従う。
入力長
処理方法
0
seedとsecretの2組の64ビット値をXORし、それぞれにXXH64 avalancheを適用
1~3
先頭・中央・末尾のバイトと長さを組み合わせて2系統の値を構成し、avalancheを適用
4~8
先頭と末尾のu32を組み合わせ、seedとsecretを混ぜて、128ビット乗算と2系統の最終ミックスを行う
9~16
先頭と末尾のu64に2組のbitflipを混ぜ、2回の乗算を行ってhigh/lowを得る
17~128
入力の両端から16バイトずつ対にして取り出し、mix32で2系統の状態を累積する
129~240
まず先頭128バイトを処理してavalancheを適用し、その後、残りの完全なグループと末尾の重複ウィンドウを処理する
241以上
8つのアキュムレータで64バイトのstripeを処理し、1024バイトごとに状態をscrambleして、最後にhigh/lowをそれぞれ統合する
中程度の長さのmix16では (secret_low + seed) と (secret_high - seed) で2つの入力u64をマスクし、その積を折りたたんで64ビットにする。mix32ではlow/highを同時に更新し、もう一方の入力に含まれる2つのワードの合計を交差XORする。129~240バイト分岐の最終ウィンドウでは負のseedを使うため、位置と方向を前の完全なグループと入れ替えてはならない。
長い入力では、seedが0でない場合、まず192バイトのsecretを派生する。各16バイト単位について、前半のu64にはseedを加え、後半のu64からはseedを引く。各stripeは8系統に分け、元の値を隣接するアキュムレータに加え、マスクした値に含まれる2つのu32を乗算して現在のアキュムレータに加える。1024バイトの完全なブロックを処理した後にscrambleを行い、最後の64バイトのウィンドウは単独で処理する。このウィンドウは直前のウィンドウと重なってもよい。最終的には異なるsecretオフセットを使ってhighとlowを統合するため、独立した64ビットハッシュを2回実行するものではない。
以下のコードは、Pythonの標準機能だけで単独実行できる。xxHash 0.8.3をPythonに移植したもので、元のアルゴリズムの著作権はYann Colletに帰属し、BSD-2-Clauseライセンスを維持している。文末にライセンス全文を掲載する。
戻り値は high64 << 64 | low64となる。x86を生成する際は、その整数を固定長32ビットの大文字hexにし、同じ入力から得た固定長8ビットの大文字CRC32を続けて連結する。
ライセンス: