那次差點放行的偽造付款回呼——我在金流與交易所學到的資安實戰課
凌晨兩點,一筆「成功」的入金
那是個禮拜三的凌晨,我手機在床頭櫃上震個不停。
不是告警系統的那種「服務掛了快起來」的震動,是客服群組。值班同事傳了一張截圖:有個用戶帳戶餘額在十分鐘內從零變成了等值約八十萬台幣的 USDT,但對應的銀行帳戶那邊,沒有任何一筆真實的進帳。
我那時候在一家加密貨幣交易所負責金流串接,法幣入金、出金、第三方支付通道都歸我管。睡意瞬間消失。我爬起來開電腦的時候手是抖的,不是因為冷,是因為我心裡已經有個很不妙的猜測——而且事後證明那個猜測八九不離十。
我想用這篇文章講的,不是「永遠不要相信使用者輸入」這種你 Google 三秒就找得到的東西。我想講的是,當這些教條在凌晨兩點變成一個正在流血的真實漏洞時,到底長什麼樣子,以及我那幾年從這些事故裡,真正刻進骨子裡的幾件事。
回到那個偽造的回呼
先把那筆八十萬講完。
我們的入金流程是這樣的:用戶在第三方支付平台付款,支付平台處理完成後,會打一個 HTTP 回呼(webhook)到我們的伺服器,告訴我們「這筆訂單成功了,金額多少,訂單號是什麼」。我們收到這個回呼,核對訂單,然後幫用戶的帳戶加錢。
聽起來很合理對吧?問題出在,當時那個接回呼的端點,沒有驗章。
更精確地說,有驗,但驗的是個假東西。當初串接的時候,支付商文件裡有提到一個 sign 欄位,是把所有參數按字典序排好、加上密鑰,做 MD5 之後的簽章。正確的做法是我們收到回呼後,自己用同樣的演算法、同樣的密鑰算一遍,比對 sign 對不對。但接手前那版程式碼裡,驗章的邏輯寫成了——我到現在都還記得那行——只檢查 sign 這個欄位「存在而且不是空字串」。
對,你沒看錯。只要你打過來的請求裡帶一個 sign 等於隨便什麼東西,它就過了。
攻擊者做的事很簡單。他在我們平台註冊一個帳號,正常走一次入金流程,用瀏覽器或抓包工具把那個回呼的格式整個錄下來。然後他自己架一台機器,改掉訂單號、改掉金額、把 sign 隨便填,直接往我們的回呼端點灌。我們的伺服器收到,看到 sign 欄位「有東西」,放行,核對訂單號——這裡還有第二個洞,訂單號的校驗也只是查存不存在,沒查那筆訂單到底付款了沒——然後乖乖地幫他加了八十萬。
他那晚試了好幾筆,金額一筆比一筆大,像在試水溫。最後那筆八十萬是他確認有效之後直接拉上去的。要不是金額大到觸發了我們的大額入金人工複核,把那筆訂單卡在待處理,客服才注意到不對勁,他大概會一路加到我們發現帳上對不平為止。
那一夜我們做了什麼
我做的第一件事不是修程式碼,是先止血。
凌晨兩點四十,我直接在閘道層把那個回呼端點的對外流量擋掉,只放行支付商的來源 IP 白名單。這招很粗暴,代價是那段時間所有人的真實入金都會卡住,但跟錢一直在漏比起來,卡住是可以接受的。當下最重要的判斷是:寧可服務降級,不要繼續失血。
第二件事是查血。我把過去七十二小時所有打到這個端點的請求撈出來,比對來源 IP、訂單號、簽章內容。攻擊者很「貼心」地用了同一個 VPS,IP 固定,而且他的假請求有個特徵——真實的支付回呼,sign 是六十四位或三十二位的固定長度十六進位字串,他偽造的那些 sign 是亂打的,長度跟字元都對不上。十五分鐘我就把所有受影響的帳戶圈出來了,總共三個帳號,假入金加起來大概一百一十萬。
第三件事才是修。我把驗章邏輯改成真的去重算 MD5 比對,同時把訂單狀態校驗補上——只有在我們主動向支付商查詢(對,反向查一次,不信任回呼的單方面說法)確認該訂單確實已支付的情況下,才入帳。這個「反向查詢」後來成了我們所有金流回呼的鐵律。
天亮之前我們凍結了那三個帳戶,假餘額沖正,留證報案。錢沒真的出得去,因為那筆八十萬還沒過提現的風控就被攔了,但那一夜我大概老了三歲。
我從這件事學到的,不是「要驗章」
「要驗章」這四個字,我相信任何一個資安投影片都會寫。但那晚之後我真正想通的是另一層東西。
第一,文件說有驗,不等於真的有驗。
最坑的地方在於,那段驗章程式碼是「存在」的。Code review 的時候,你看到一個叫 verifySignature 的函式被呼叫了,你很容易就在心裡打個勾:喔,有驗。但魔鬼在函式裡面。從那之後,我看任何安全相關的程式碼,都不看名字,只看它到底做了什麼比對、比對的兩邊各是什麼、不通過的時候會不會真的擋下來。一個 return true 寫死在裡面的驗證函式,比沒有驗證更危險,因為它給你一種虛假的安全感。
第二,單層防線一定會破,問題只是哪天破。
如果那筆假入金當下就能直接提現出去,我們那晚損失的就不是帳面數字,是真金白銀流到鏈上,追都追不回來。救了我們的,是入金跟提現之間還隔了好幾道:大額人工複核、提現風控、帳戶異常行為偵測。驗章那層破了,但後面還有人在守。
這就是縱深防禦。我以前覺得這個詞很虛,是顧問拿來騙預算的。直到那晚我親眼看到,正是因為「一層破了還有下一層」,我們才從必死的局面裡爬出來。從那之後我設計任何系統,都會問自己一個問題:如果這一層完全失效——不是出 bug,是被完全繞過——下一層擋得住嗎?如果答案是「擋不住,直接就出事了」,那這個設計就不及格。
第二個故事:那個能看別人訂單的 bug
如果說偽造回呼是被外面的人打,那越權這件事,是我們自己挖的坑。
那是後台系統。交易所的客服跟風控人員會用一個內部後台查用戶資料、看訂單、處理工單。後台有權限分級,客服只能看自己負責的工單,風控才能看全站。聽起來權限設計得很清楚。
問題出在查單頁面的 API。前端呼叫的時候會帶一個 order_id,後端拿到 order_id 就去資料庫撈那筆訂單回傳。撈之前,後端會檢查「你這個登入的人有沒有查單的功能權限」——客服有,所以放行。
你看出問題了嗎?它檢查了「你有沒有查單這個功能」,但沒檢查「這筆訂單是不是你該看的」。
這是兩種完全不同的授權。一種叫功能授權(你能不能用這個功能),一種叫資料授權(這筆資料是不是你的範圍)。我們做到了前者,漏了後者。結果就是,任何一個客服,只要把 order_id 改成別的數字——而且我們的 order_id 是連號自增的,改起來毫無難度——就能看到全站任何一筆訂單,包括其他客服在處理的、包括 VIP 大戶的、包括理論上只有風控能碰的敏感資料。
這個洞沒有被外部攻擊者利用,是一個資深客服自己發現的。她在處理工單時手滑改錯了 order_id,結果跳出來一筆不是她的單,她覺得不對勁就回報了。我很感謝她,因為這種「水平越權」是最安靜的漏洞,它不會讓系統當機、不會讓帳對不平,它就只是默默地讓不該看到的人看到不該看到的東西,可能幾個月、幾年都沒人發現。
修法不難:每次撈訂單之前,多一個判斷,這筆訂單的負責人是不是當前登入者,或者當前登入者是不是有跨範圍權限的角色。難的是,我們得回頭把整個後台幾十個查詢端點全部審一遍,因為這種「只驗功能、不驗資料」的寫法,一旦團隊養成習慣,它會像影印一樣出現在每一個類似的端點裡。我們那次大概補了二十幾處。
我從這裡學到的是:權限這件事,id 是誰給的決定一切。前端傳過來的 order_id、user_id,本質上都是「使用者宣稱的東西」,而使用者宣稱的東西永遠不可信。真正能信的,只有伺服器這端從 session 或 token 裡解出來的「你是誰」。任何一個用前端傳來的 id 去當作授權依據的地方,都是一個潛在的越權洞。
第三個故事:一句太誠實的錯誤訊息
這個比較小,但我印象很深,因為它教會我一個反直覺的道理:有時候,把事情講清楚反而是漏洞。
我們的登入接口,早期為了「使用者體驗好」,錯誤訊息分得很細。帳號不存在,回「此帳號未註冊」;帳號存在但密碼錯,回「密碼錯誤」。產品經理很滿意,覺得這樣使用者知道是哪裡填錯了。
聽起來很貼心,直到有人拿這個來跑帳號枚舉。
攻擊者寫個腳本,拿一堆從別的網站外洩的 email 來我們登入接口試。他不用知道密碼,他只要看回應——回「此帳號未註冊」的,代表這個 email 在我們平台沒帳號;回「密碼錯誤」的,代表這個 email 是我們的用戶。跑個幾萬筆,他就精準地撈出了一份「在這家交易所有帳號的人」的名單。對一家交易所來說,「誰是我們的用戶」本身就是高度敏感的資訊,等於告訴攻擊者「往這些帳號打,至少帳號是對的,接下來只要爆密碼」。
我們是從風控的異常請求量警報裡發現這件事的,某個 IP 在短時間內對登入接口打了上萬次,而且請求的 email 全都不一樣。順著查下去才意識到,問題的根源是我們的錯誤訊息太誠實了。
修法是把帳號不存在跟密碼錯誤統一回一句模糊的「帳號或密碼錯誤」。使用者體驗確實差了一點點,但攻擊者再也沒辦法從回應裡分辨出哪些 email 是有效帳號了。
這件事讓我重新理解了一個取捨:系統對外吐出的每一個訊息——錯誤碼、回應時間、甚至回應的長度——都是資訊,而資訊會被用來攻擊你。 那次之後我還發現,光統一錯誤訊息還不夠,如果「帳號不存在」走的程式碼路徑很短、回應很快,「密碼錯誤」要去算一次密碼雜湊、回應慢個幾十毫秒,攻擊者照樣能從回應時間的差異分辨出來。所以我們後來連那個都補上了,讓兩條路徑的耗時盡量一致。資安到後面拼的就是這種你想都沒想過的細節。
縱深防禦,在我心裡長什麼樣子
講了三個故事,我想把它們串起來。
第一個故事,偽造回呼,告訴我外面隨時有人在敲門,而且他比你想像的更會找你最弱的那道鎖。第二個故事,越權,告訴我傷害不一定來自外面,你自己團隊養成的壞習慣會在內部複製成幾十個一模一樣的洞。第三個故事,錯誤訊息,告訴我連你出於好意的設計都可能反過來成為攻擊面。
這三件事的共同點是:沒有任何一道單獨的防線是夠的。
驗章很重要,但驗章那層破了,救我的是後面的大額複核跟提現風控。授權很重要,但功能授權做了,還得有資料授權;就算這兩層都漏了,我們後來還加了一層——後台所有查敏感資料的操作都記稽核日誌,真出事至少查得到是誰、看了什麼。錯誤訊息要模糊,但光模糊不夠,還得補上回應時間一致、補上登入失敗的頻率限制、補上異常請求的風控告警。
所謂縱深防禦,不是一句口號,是你真心相信「我寫的每一層都會破」之後,自然會去鋪的下一層、再下一層。它的核心是一種悲觀:預設你做的驗證有一天會失效,預設你的同事會寫出有洞的程式碼,預設攻擊者比你聰明、比你有耐心、比你了解你的系統。在這種悲觀的前提下,你才會去想——萬一呢?萬一這層破了,接下來會怎樣?
我也想誠實講一下取捨,因為資安從來不是「越多越好」。每多一層防線,就多一份延遲、多一份複雜度、多一個可能出錯的地方。那個反向查詢付款狀態的設計,讓我們入金確認多了幾百毫秒;那個統一錯誤訊息,讓真實使用者偶爾搞不清楚自己到底是帳號錯還是密碼錯;那些稽核日誌,佔了不小的儲存成本。資安是拿可用性、開發速度、使用者體驗去換的,你得想清楚你在換什麼、值不值得。我的原則是:跟錢直接相關的、跟用戶資產直接相關的、一旦破了就追不回來的,不計成本鋪滿;其他的,在風險跟成本之間找平衡。
寫在最後
那筆八十萬最後一塊錢都沒真的流出去,但我從來不覺得那是我們「守住了」。我們是運氣好——好在攻擊者貪心,好在金額大到觸發了一道本來目的根本不是防駭客、只是防洗錢的人工複核。如果他每筆只刷個幾千塊、慢慢來,我們很可能幾個禮拜都不會發現。
做了這些年金流跟交易所,我最深的體會是,資安不是一份你做完可以打勾的清單,它是一種你看待自己系統的眼光。同樣一段加錢的程式碼,新手看到的是「功能完成了」,而被半夜的告警電話訓練過的人看到的是「這裡如果有人偽造請求會怎樣、如果這個 id 被改掉會怎樣、如果這條路徑被人打一萬次會怎樣」。差別不在你知道多少招式,在你有沒有那種預設世界充滿惡意的本能。這種本能不是讀來的,是被每一次半夜爬起來查血、每一筆差點漏掉的錢、每一個事後冒冷汗的瞬間,一點一點養出來的。如果你還沒有,我真心希望你是從別人的故事裡學到,而不是像我一樣,從自己的凌晨兩點學到。
留言討論
有想法、有不同經驗、或想糾正我?歡迎在下面留言,免註冊,填個暱稱就能留。