小紅書のシェアリンクが解析できなくなった。調べていくうちに、このプラットフォームのアンチクローリングが想像以上に複雑だと気づいた。いくつもの方法を試した結果、最終的な解決策は意外なほどシンプルだった。
きっかけ
小紅書のリンク解析ツールを持っていて、ノートからタイトル、著者、画像などの情報を抽出してい���。ある日、あるシェアリンクが解析できないと報告があった:
aiohttp でリクエストし、リダイレクトを追跡すると、最終的に /404?errorCode=-510001 に行き着く。ブラウザで同じリンクを開くと問題なく表示される。
一体どういうことだ?
短縮リンクのリダイレクトと xsec_token
curl でリダイレクトチェーンを追跡してみる:
短縮リンクはまず 302 でノートページにリダイレクトされるが、ノートページがさらに 302 で 404 にリダイレクトされる。キーとなるパラメータは URL 中の xsec_token——これは小紅書のホットリンク防止トークンで、サーバー側で現在のセッションとの整合性を検証する。
ブラウザで興味深い現象を観察した:シークレットモードでの初回アクセスも 404 だが、一度リロードすると正常に表示される。2 回のリクエストの差分を比較すると、2 回目には一連の Cookie が追加されていた:
これらの Cookie はサーバーが Set-Cookie で発行したものではなく、ページ内の JavaScript で生成されたものだ。純粋な HTTP クライアントではこれらを取得できない。
UA を変えてみる
デスクトップ版では JS で Cookie を生成しないと検証を通過できない。そこで発想を変えて、モバイル端末に偽装してみた:
直接 HTTP 200 が返ってきて、ページも完全に表示された。追加の Cookie は一切不要。
考えてみれば合理的だ。App 内の WebView では完全なアンチクローリング JS を実行できるとは限らないため、モバイル版シェアリンク(app_platform=ios)はより緩いパスを通り、xsec 関連の Cookie を検証しない。
解析は成功したが、画像にはすべてウォーターマークが付いていた。
URL はこんな形式:
!h5_1080jpg は画像処理命令で、CDN 層でウォーターマークをレンダリングする。サフィックスを削除しても署名検証で直接 403 になる。ウォーターマークは画像処理パイプラインの一部であり、クライアント側で付加されたものではない。
API プロトコルに正面から挑む
モバイル版ページにウォーターマークがあるなら、API を直接叩いて生データを取得してみよう。
オープンソースプロジェクト RedCrack を見つけた。小紅書の Web API プロトコルを実装しており、edith.xiaohongshu.com の /api/sns/web/v1/feedエンドポイントからノートの JSON を取得し、返される画��� URL にはウォーターマークがない。
素晴らしい話に聞こえる。しかしこの方法を使うには、いくつもの関門を突破する必要がある:
Cookie 生成チェーン
リクエスト署名
各 API リクエストには 5 つの署名ヘッダーが必要:
| Header | 生成方法 |
|---|---|
x-s | MD5(url+body) → XOR → Base58 エンコード、外層は独自 Base64 |
x-s-common | ARC4 でフィンガープリントのサブセットを暗号化 → 独自 Base64 |
x-b3-traceid | 16 桁のランダム hex |
x-xray-traceid | タイムスタンプ左シフト + シーケンス番号 + 乱数 |
x-t | 現在のミリ秒タイムスタンプ |
x-s のコアは _encrypt_x3 で、ブラウザ内の window.mnsv2() の出力に対応する。この関数自体は JSVMP(JS 仮想マシン保護)の中で実行されており、通常の JS で直接読み取ることはできない。
壁���ぶつかる
RedCrack の暗号化ロジックを独立モジュール xhs_encrypt_helper.py(約 325 行)として抽出し、さらに xhs_api_client.py で完全なセッション初期化と API 呼び出しを実装した。
Cookie 生成チェーンは問題なく動き、a1、webId、websectiga、gid、web_session をすべて取得できた。しかし feedエンドポイントを呼び出すと:
461 は小紅書の「アクセス異常」ステータスコードで、Verifytype: 216 が付与されている。
さらにテストすると、RedCrack の原プロジェクト自体も同じステップで失敗していた。webprofileエンドポイントが 471 を返し、Verifymsg を base64 デコードすると:
本番環境の vendor-dynamic.js を分析すると、ARTIFACT_VERSION が 4.83.1 から 6.3.0 に変わっていた。バージョン番号を更新すると webprofile は正常に戻ったが、feedエンドポイントは依然として 461 のままだった。
根本原因:mnsv2 の JSVMPバイトコードがサーバー側で更新されていた。小紅書は定期的に VM 命令セットを変更しており、以前リバースエンジニアリングして得た _encrypt_x3 関数で生成した署名はもう受け入れられない。
API ルートは行き止まりだ。
CDN URL の書き換え
立ち返って、モバイル版 HTML 方式を見直す。画像にはウォーターマークがあるが、URL を詳しく分解してみる:
202604070308 はタイムスタンプ、その後の hash はホットリンク防止署名、!h5_1080jpg は画像処理命令(リサイズ +ウォーターマーク)。
そこで、小紅書には別の CDN ドメイン sns-img-qc.xhscdn.com があり、画像 ID だけで原画を返し、署名不要、ウォーターマークなしということを発見した:
いくつかの組み合わせを試した:
| URL パターン | ステータス | ウォーターマーク | 署名必要 |
|---|---|---|---|
sns-webpic-qc.xhscdn.com/DATE/SIGN/ID!h5_1080jpg | 200 | あり | はい |
sns-webpic-qc.xhscdn.com/DATE/SIGN/ID(サフィックス削除) | 403 | - | - |
sns-img-qc.xhscdn.com/ID | 200 | なし | いいえ |
ci.xiaohongshu.com/ID | 200 | なし | いいえ |
つまり最終的な方法は非常にシンプルだ。モバイル版 HTML のカルーセルから画像 URL を抽出し、IMAGE_ID を解析して、ドメインを差し替えるだけ:
すべての画像をウォーターマークなしのバージョンで取得でき、すべて直接アクセス可能だ。
小紅書のアンチクローリングはどうなっているか
今回の調査を通じて、小紅書の防御レイヤーの全体像がおおよそ見えてきた:
| レイヤー | メカニズム | 説明 |
|---|---|---|
| 1 | xsec_token | URL レベルのホットリンク防止トークン、セッションに紐づく |
| 2 | JS Cookie 生成 | a1/webId/websectiga/gid などが JS ランタイムで生成される |
| 3 | ブラウザフィンガープリント | 80 以上のフィールドのデバイスフィンガープリント、DES で暗号化して送信 |
| 4 | リクエスト署名 (x-s) | JSVMP 仮想マシンで実行、定期的にバイトコードを変更 |
| 5 | TLS フィンガープリント | ブラウザ以外の TLS ClientHello 特徴を検出 |
| 6 | バージョン検証 | ARTIFACT_VERSION が期限切れだと即 471 |
| 7 | 行動分析 | 頻度、軌跡、タイミングなどの総合的なリスク制御 |
第 4 層が本当の難関だ。mnsv2 は署名アルゴリズムをカスタムバイトコードにコンパイルし、JS 仮想マシン内で実行する。これをリバースエンジニアリングするには:
vendor-dynamic.xxx.jsから VMインタプリタを見つける- バイトコード配列を抽出する
- 命令を一つずつシミュレーション実行し、アルゴリズムロジックを復元する
- Python で再実装する
しかし小紅書はバイトコード配列を更新するだけで(VM 構造は変えずに)、以前のリバースエンジニアリングの成果がすべて無効になる。攻撃者は毎回ゼロからやり直さなければならないが、防御側は配列を変えるだけでいい。コストの非対称性だ。
バイパス手法の比較
| 方法 | 難易度 | 安定性 | ウォーターマーク |
|---|---|---|---|
| モバイル UA + CDN 書き換え | 低 | 高 | なし |
| Playwright/Puppeteer で実ブラウザ実行 | 中 | 高 | なし |
| JSVMP 署名アルゴリズムのリバースエンジニアリング | 極めて高 | 低(いつでも無効化される) | なし |
| サードパーティ解析 API の利用 | 低 | サードパーティ次第 | なし |
最終的に最初の方法を選んだ:モバイル UA で HTML を取得 + CDN URL 書き換え。JS の実行に依存せず、署名のリバースエンジニアリングも不要で、バージョン更新の影響も受けない。小紅書が sns-img-qc の CDN を閉鎖するか、モバイル版ページを大幅に変更しない限り。
技術的な詳細
この部分は自分(あるいは後から来る人)のためのメモで、興味がなければ読み飛ばして構わない。
Cookie の生成方法
a1:hex(timestamp_ms) + 30 文字のランダム文字 + プラットフォームコード "50000" + CRC32 チェックサム、先頭 52 文字を切り出す。
websectiga:POST /api/sec/v1/scripting で難読化された JS を取得。その中に base64 エンコードされたルックアップテーブルとインデックス配列があり、特定のオフセットで復号して 64 文字の鍵文字列を得る。
gid:80 以上のフィールドのブラウザフィンガープリントを JSON 化 → Base64 → DES-ECB 暗号化(鍵 zbp30y86)→ hex、webprofileエンドポイントに POST する。
x-s-common:フィンガープリントのサブセットを取得 → JSON → ARC4 暗号化(鍵 xhswebmplfbt)→ URL エンコード → 独自 Base64(コード表 ZmserbBoHQtNP+wOcza/...)。
バージョン番号
vendor-dynamic.js 内の getArtifactInfo 関数にバージョン番号がハードコードされており、正規表現 artifactVersion.*?(\d+.\d+.\d+) で本番 JS から抽出できる。
コード構成
async_xhs.py の呼び出しフロー:
免責事項
本記事は個人の技術学習過程における探索と考察を記録したものであり、セキュリティ研究および教育目的で使用されるものです。記事中のすべての技術分析は、公開アクセス可能なページおよびネットワークリクエストを対象としており、不正アクセス、大量データ収集、商用利用は一切含まれていません。関連プラットフォームの利用規約および現地の法律法規を遵守し、本記事の内容を他者の正当な権利を侵害する目的で使用しないでください。
あとがき
今回の試行錯誤で得た最大の教訓:最初から最も複雑な方向に突き進むな。
自分の思考プロセスは「デスクトップ版は Cookie が必要 → なら Cookie を生成しよう → Cookie が揃ったらリクエストに署名 → 署名が合わないなら JSVMP をリバースエンジニアリング」と、一本道で袋小路に突き進んだ。最終的な解決策はまったく別の方向にあった:UA を変えて、CDN ドメインを変えるだけ。
sns-webpic-qc(署名付き、ウォーターマークあり)と sns-img-qc(ID のみ、ウォーターマークなし)はおそらく異なる用途のために用意されたものだ。前者はユーザーの閲覧用で、後者はおそらく内部サービスや App のネイティブレンダリング用だ。後者が署名を必要としないのは、もともと外部に公開する想定がなかったからだろう。しかしモバイル版 HTML 内の画像 ID が、この 2 つのシステムをつなげてしまっている。
この扉はいつか閉じられるかもしれない。しかし少なくとも今日は、まだ開いている。