vatt'ghern jaskier's ballads

把同一句廣告詞裡的一個英文字母換成長得一模一樣的西里爾字母,Reddit 拿來判垃圾的 Perspective 分數就從 0.6473346 掉到 0.4452748。一道號稱核心的防線,原來只要動兩個字元就能滑過去。

扒開 Reddit 反垃圾系統的七層防線——一場靠洩漏 log、七年舊原始碼與重現 API 拼出來的逆向工程

2021 年某天,作者用的 Reddit 客戶端 Relay 突然開始狂推一堆奇怪的「已移除垃圾」通知。照他的說法,那是 Reddit 端出了某種錯誤,平常顯示的 Removed: Auto 被換成了真正的移除原因——那一瞬間,他看到了一段本來絕對看不到的東西:反垃圾系統在每則被刪內容旁邊留下的真實理由。這個本不該外洩的瞬間,就是整場調查的起點。

但一行洩漏的 log 不等於一張系統架構圖。作者手上沒有任何文件,只有碎片:那段意外洩漏的移除原因、一份 2017 年最後公開的 Reddit 原始碼封存版、幾個截圖。於是真正的問題不是「Reddit 反垃圾長什麼樣」,而是更尖的一個——一套被刻意藏起來的反濫用系統,光靠幾次意外洩漏,到底能證明出多少?哪些是你真的看到的,哪些只是你看著數字猜的?開頭那個西里爾字母把分數從 0.6473346 拉到 0.4452748 的把戲之所以成立,前提是你得先證明 Reddit 真的在用 Perspective——而這恰恰是這場調查最難、也最關鍵的一步。底下每一節,都是拿一塊碎片去試著回答「這層到底是不是真的」。

調查的方法本身很笨,但正是它把「看到的」和「猜的」分開:把同一個訊號的多個樣本擺在一起,看數值的規律,再回去翻七年前的原始碼有沒有對得上的函式名稱或常數。對得上、或能用別的手段重現的,才敢說「確認」;對不上、只能靠 log 格式猜的,老實標「推測」。這個分級不是事後整理的書架,而是調查過程中一層一層篩出來的結果——有一個橫在中間的難處讓篩選更難:那份原始碼是 2017 年的,作者自己也提醒「從那之後改了很多,但我們還是能分析封存的程式碼、推測現在可能在發生什麼」。程式碼證據舊了七、八年,log 卻是近幾年的,等於拿過期的地圖去對照新地形。網域黑名單、unidecode 這類舊碼裡找得到的,骨架大概還在;Spamurai、Perspective、影像層全是舊碼之後長出來的,只能靠 log 與外部文件補——這道時間落差,是每一個「確認」都得先跨過的門檻。

第一塊碎片能證明什麼——哪一層真的看得到

先從最容易下判斷的碎片開始。一條典型的記錄長這樣:某個網域被標記為 banned as an experiment to see what happens with tubmlr spam ring。這明顯是某種網域黑名單留下的痕跡,但「log 裡有這串字」本身還不能算證明——任何人都能寫一行看起來像系統訊息的東西。讓它升格成「確認」的,是它能在公開原始碼裡對到實際的封禁邏輯:log 的字串與碼裡的機制兩頭對得上。這是整場調查裡少數可以兩面印證的一層,作者也直說,網域黑名單「是我們在公開的 Reddit 原始碼裡唯一真的看得到的」。

接著問題就變難了。log 裡還夾著各種數字與欄位——有的像百分比(72.98% spammy),有的是一串小數(0.12571795 perspective spam),有的直接寫出 ISP 名稱、email 網域、一段 TLS 指紋。每一種格式看起來都對應一個子系統,但「看起來對應」距離「證明存在」還很遠。下面這張圖先把所有候選層攤開,每一層暫時掛著一個待驗的判決——點開任一層可以看作者最後怎麼下這個判決,以及他憑什麼。把它當成調查的待辦清單:哪些後來坐實了,哪些到最後也只能存疑。

Reddit 反垃圾的數層防線——每層後面標的是作者自己的把握程度

Reddit 反垃圾的數層防線(標籤=作者把握程度) 網域黑名單 封掉特定網域;公開原始碼唯一直接可見的一層 確認 Spammit 百分比評分 給每則內容一個垃圾百分比;作者觀察到常誤判 Imgur 推測 Google Perspective API 外接毒性/垃圾分數;作者用洩漏 key 重現相同數值 確認 Spamurai 行為剖繪 ISP、email、user-agent、TLS 指紋、帳號年齡 推測 Unidecode 轉寫後 regex 轉寫成 ASCII 再比對關鍵字;原始碼有用到 unidecode 推測 影像 OCR 與分類 2024 年起 Hive AI 與 Google Vision OCR;來源 eSafety 文件 外部佐證

點任一層看作者怎麼判斷它是確認還是推測

網域黑名單 · 確認

這層在 log 裡留下 banned as an experiment to see what happens with tubmlr spam ring 這類字串,而且能對到公開原始碼裡的網域封禁邏輯。是少數作者能說「真的看得到」的部分。

Spammit 百分比評分 · 推測

log 裡出現 72.98% spammy 這種百分比格式。作者直言 Spammit「在我管的版上看起來不是個很準的反垃圾手段,因為它打到很多合法的 Imgur 貼文,給出 70-98% 的垃圾評分」。這層的存在與行為都靠觀察樣本推出來。

Google Perspective API · 確認

log 裡的 0.12571795 perspective spam 讓作者起疑——他說自己很確定,因為 perspective 這個詞很獨特。他拿洩漏的 API key 去打 Perspective,重現出一模一樣的分數,等於外部交叉驗證了這層。

Spamurai 行為剖繪 · 推測

log 帶出 ISP(如 org: Skyinfo Online)、email 網域、一段 TLS 指紋、帳號年齡等欄位。作者把這套行為畫像稱為 Spamurai,但坦承「不確定這怎麼被算進移除原因,所以乾脆忽略、假設一切都是 spamurai」。

Unidecode 轉寫後 regex · 推測

公開原始碼裡用到 unidecode——把 Unicode 特殊字元轉寫成最接近的 ASCII。合理的推測是:轉寫之後再跑 regex 比對關鍵字,避免用花式字元繞過。但這層怎麼跟其他層串起來,作者沒有直接證據。

影像 OCR 與分類 · 外部佐證

這層不在 2017 原始碼裡,來源是一份澳洲 eSafety 的文件:Reddit 自 2024 年起用 Hive AI 做 OCR 與影像/影片分類,也用 Google Vision OCR API。屬於有外部文件佐證、但與 log 那套系統怎麼接合不明的部分。

那串小數能不能坐實——把 Perspective 從懷疑變成證據

清單上最值得花力氣攻的,是那串 0.12571795 perspective spam。它跟其他欄位不一樣:別的訊號只能靠格式猜,這一個卻給了一條可以親手檢驗的線索。作者的第一個假設很簡單——這是 Google 的 Perspective API,因為 perspective 這個字本身就夠特殊,不像是隨手取的內部代號。但「名字像」還不是證據。真正把它從懷疑推到確認的,是他拿一把洩漏的 API key 去打同一個服務,餵進對應的文字,得到的分數跟 log 裡完全吻合。到這一步,這層才從「看起來像」升格成「我重現了它」——這是整場調查裡唯一一個能完整對上、不靠推測的判決。

而坐實這層的同時,調查也撞上了那個本不該成立的開頭症狀:一旦能重現,就能拿來戳,戳完才發現這道防線根本擋不住。

問題是,一旦能重現,就能拿來戳。Perspective 的設計目的是評毒性與垃圾傾向,輸出一個 0 到 1 的分數。作者發現它對「只改幾個字元」極度敏感。最直接的示範是西里爾字母替換:把 Buy my product 的 p 換成長得一模一樣的西里爾 р,分數從 0.6473346 掉到 0.4452748。肉眼看不出差別,分數卻明顯往下走。

更誇張的是窮舉。作者把訊息裡所有兩字母組合都試了一遍,照他的話:「你可以看到把所有兩字母組合跑過一遍,讓我們從 86% 的垃圾分數一路降到 1%。」一個本來會被判垃圾的訊息,靠機械式地微調幾個字元,就能滑到幾乎乾淨。下面這張圖把這條敏感曲線畫出來——拖滑桿增加替換的字元數,看分數怎麼塌下去。

0
替換的字元數(兩字母微調) Perspective 垃圾分數 0.86 0.01 分數隨微調塌落
兩端的 86% 與 1% 取自作者原文的窮舉示範;中間曲線為說明用的插值,並非作者逐點量到的資料。重點不是準確曲線,而是斜率——只動幾個字元,一道「核心」防線就被拉到地板。

作者下的結論很直接:這代表「一直以來,壞人只要改一下訊息,就能繞過 spamurai 的主要判準之一」。注意他的措辭——他把 Perspective 當成 spamurai 行為剖繪裡的一個輸入,而 Perspective 這層本身又脆弱到改幾個字元就失效。對任何想靠單一文字模型擋垃圾的人,這是一個很實際的警告:模型對輸入的微小擾動越敏感,攻擊者繞過的成本就越低,而 Perspective 這種早期模型在這方面幾乎不設防。

這個發現之所以值得單獨拉出來講,是因為它觸到一個更普遍的問題:把「判斷一段文字是不是垃圾」外包給一個通用的毒性模型,本來就帶著結構性的脆弱。Perspective 不是為了對抗主動規避而設計的,它衡量的是「這段話聽起來有多惡意」,而垃圾發送者要的不是聽起來無害,是分數低。當分數低就放行、而分數又對單字元擾動這麼敏感時,攻擊者的最佳策略不是改寫訊息語意,而是機械地掃過所有微小替換,找出能把分數壓到門檻以下的那一組。作者的窮舉示範正是這個策略的具體版本——他不是在寫一封更禮貌的廣告,他是在對一個可微的評分函式做梯度下降。

要公平地看:作者自己也保留了餘地。他說不確定 Perspective 的分數實際上怎麼被組合進最終的移除決策——他選擇「忽略、假設一切都是 spamurai」。所以「改字元就能完全繞過整套反垃圾」這種強斷言,並不是他講的;他講的是「繞過 Perspective 這層」。中間那段組合邏輯,仍然是黑箱。也因為是黑箱,繞過 Perspective 之後會不會被別層(例如 unidecode 轉寫後的 regex)接住,作者並不知道——他能確證的只有「這一層本身擋不住」,不是「整套系統擋不住」。

剩下的碎片卡在哪——為什麼有些層只能停在「推測」

Perspective 是少數能收尾的。換到其他碎片,同一套方法走到一半就卡住——卡點不在材料不夠多,在於沒有第二條獨立線索能交叉印證。Spamurai 這個名字是作者給一整組行為訊號的統稱:log 裡同時出現 ISP 組織名(org: Skyinfo Online,他註解說這能看出使用者來自哪、有沒有用 VPN)、email 網域、一段類似 JA3 的 TLS 指紋(TLS: SwxwvfHLtTxt/9qbo…,他自己標明「這是 TLS 指紋,類似 JA3」)、還有帳號年齡。這些欄位擺在一起,明顯是在做一張「這個帳號可疑嗎」的行為畫像。但畫像怎麼算成分數、分數怎麼變成移除,作者沒有證據,只能擺著。

連欄位的單位他都得用推理補。帳號年齡在 log 裡寫成 2.948587962963 days old 這種帶一長串小數的天數——作者說他相信底層其實是用秒在存,因為他手上的例子換算回去都是整數秒。這是個小細節,卻很能說明整篇的研究方法:沒有文件,就拿多個樣本的數值規律去反推內部表示。下面這張表把 Spamurai 抓的幾個行為訊號列開,附上 log 裡的原始樣本與作者對它的解讀——能點欄位排序。

點欄位標題排序 · 3 欄 × 4 列

行為訊號 log 原始樣本 作者的解讀
ISP 組織org: Skyinfo Online看使用者從哪來、有沒有掛 VPN
email 網域email: gmail.com判斷註冊用的信箱類型
TLS 指紋TLS: SwxwvfHLtTxt/9qbo…作者標明類似 JA3 的指紋
帳號年齡2.948587962963 days old作者推測底層以秒儲存

另一條推測線是文字比對。2017 的原始碼裡用到 unidecode——把 Unicode 字元轉寫成最接近的 ASCII,例如把帶重音的字母、全形字、甚至某些表情符號降成普通拉丁字母。合理的推測是:先轉寫、再跑 regex 比對黑名單關鍵字,這樣攻擊者就不能靠花式 Unicode 變體繞過純文字過濾。諷刺的是,這正好是 Perspective 那層守不住的地方——西里爾替換能騙過 Perspective,卻很可能被 unidecode 轉寫後的 regex 抓回來。兩層的弱點互補,但作者沒有證據說它們真的被設計成這樣協作,這只是從原始碼用到的工具反推的可能性。

Spammit 那層也屬於這一類。它在 log 裡以百分比現身(72.98% spammy),作者觀察到它對合法的 Imgur 貼文很不友善,常給出 70-98% 的高垃圾分。它確實存在、確實在打分,但打分的依據是什麼,同樣在文件之外。把這幾層擺在一起看,會發現作者誠實地把「我重現過的」與「我從格式猜的」用不同的語氣標了出來——這是這篇東西最值得學的地方,比任何單一技術細節都重要。

把時間線當第三條線索——它能補強推測嗎

當一個訊號無法兩面印證時,還有一種補強的辦法:看它在時間上排不排得進去。作者就是用這招去處理規則引擎這條線。據他的整理:REV1 在 2016 年建立,2021 年開發了 Snooron 來把 REV1 現代化,兩年後一切又遷到了 REV2。REV1 與 REV2 都跑 Lua 規則;Snooron 則跑在 Flink Stateful Functions 上。換句話說,中間那代換了底層引擎架構,最後又回到 Lua 規則的形式——這個「繞一圈回來」的形狀本身就值得玩味。

但時間吻合度只是弱證據,撐不起「確認」。作者把 REV1 跟他稱為 spamurai 的東西對上時,用的是「我覺得有兩種可能的結論」「時間點看起來吻合」這種語氣——他是靠年份排得起來在猜 REV1 就是 spamurai,不是有人告訴他。年代對得上不代表是同一個東西,這條線索能讓推測站得稍微穩一點,卻沒辦法把它推過確認的門檻。再加上 2024 年起的影像處理(Hive AI 與 Google Vision OCR)來自一份 eSafety 文件,跟原始碼那套是兩個不同的證據來源。下面這條 scrubber 把這幾個節點放上時間軸——拖動可以看任一年 Reddit 規則引擎大致跑在什麼上面。

2016
REV1 建立——Reddit 規則引擎以 Lua 規則運作。
節點年份取自作者整理;REV1 即 spamurai 的對應是作者依時間吻合度的推測,非確證。

對一個工程讀者來說,這條演進線有一個現實的提醒:規則引擎換底層(Lua → Flink Stateful Functions → 再回 Lua 形式)這種來回,在大型反濫用系統裡並不罕見。Flink Stateful Functions 適合做有狀態的串流判斷——例如累積某帳號近期的行為再下結論——而 Lua 規則勝在好寫、好讓非工程的審核團隊維護。誰主導、什麼時候用哪個,往往不是技術最優解,而是組織與維運的取捨。作者重建的這條線即使有推測成分,形狀也很可信。

「繞一圈又回到 Lua」這個形狀其實透露了取捨的方向。把判斷邏輯放進 Flink 這種串流引擎,好處是能做有狀態、跨時間窗的判斷,壞處是改一條規則就得碰工程管線、部署成本高、能改的人少。Lua 規則反過來:表達力有限,但審核團隊能自己改、能快速上線一條新規則去擋當下正在爆的垃圾手法。反濫用是一場每天都在變的貓抓老鼠,能不能在幾分鐘內出一條新規則,往往比規則本身多聰明更要緊。從 REV2 回到 Lua 形式,很可能就是這個「迭代速度優先」的判斷在主導,而不是技術退步。

2024 年的影像層是這條時間線的尾端。OCR 加影像分類進場,對應的是垃圾從純文字往圖片、影片擴散的趨勢——把廣告字嵌進圖片、用截圖規避文字過濾,這時光靠 unidecode+regex 與 Perspective 就不夠了,得讓系統先把圖裡的字認出來。Hive AI 與 Google Vision OCR 的角色就在這裡。它跟前面那些不一樣:證據不來自 log,也不來自舊原始碼,而是一份澳洲 eSafety 文件——又一條獨立來源,所以它能站住,但站住的是「Reddit 用了這些工具」,不是「它怎麼跟 log 那套接合」。

把所有判決收回來,回答開頭那個問題:光靠幾次意外洩漏,到底能證明出多少?答案比直覺的少。能兩面印證、真正坐實的只有兩處——網域黑名單(log 對得上原始碼)與 Perspective(log 對得上重現的 API 分數);其餘全停在不同強度的推測上,Spamurai 的組合邏輯是黑箱,REV1 的身分靠年份硬排,影像層只證得了工具、證不了接法。而唯一被徹底坐實的那層,恰好就是最脆弱的那層——只要動幾個字元,分數就從 86% 滑到 1%。作者自己也把這份證據的有效期講明了:他說 Perspective「在今年底就要關掉了」,懷疑 Reddit 早就不用、就算用也很快得遷走,加上 LLM「徹底翻新了垃圾產業」,所以推測整套系統「應該已經大改過很多」。換句話說,這場調查能釘死的那一根釘子,釘的是一塊正在被拆掉的板子。

What we can prove:逆向一套藏起來的系統,真正的成果不是那張看起來很完整的架構圖,而是每一塊磚頭後面的證據等級——能重現的(Perspective)、兩面對得上的(網域黑名單)、只靠 log 格式或年份硬排的(Spamurai、REV1 身分),三者不能等量齊觀。下次看到這類「我逆向出了某大廠內部系統」的文章,先別問它畫得多細,先問它每一句各站在哪一級證據上;而被釘死的那層也順帶留了一課——越敏感於輸入微擾的文字模型,越好繞。