驗證碼的初衷是人機識別。不過大多時候,只是用來增加一些時間成本,降低頻率而已。
如果僅僅是為了消耗時間,能否不用圖片,完全用程序來實現?
先來思考一個問題:寫一個能消耗對方時間的程序。
消耗時間還不簡單,休眠一下就可以了:
#!cpp
Sleep(1000)
這確實消耗了時間,但并沒有消耗 CPU。如果開了變速齒輪,瞬間就能完成。
要消耗 CPU 也不難,寫一個大循環就可以了:
#!cpp
for i = 0 to 1000000000
end
不過這和 Sleep 并無本質區別。對方究竟有沒有運行,我們從何得知?
所以,我們需要一個返回結果 —— 只有完整運行才有正確答案。
#!cpp
result = 0
for i = 0 to 1000000000
result = result + i
end
return result
通過返回的結果,我們就能判斷對方是否完整的運行了程序。
不過上面這個問題,畢竟還是 too simple。小學生都知道,用數列公式就可以直接算出結果,根本不用花時間去跑循環。
那么有什么算法,是無法用公式推測的?
顯然,單向散列函數就是。例如一個經典的問題:
#!cpp
MD5(X) == X
就無法用公式來解決了。要找出答案,只能一個個窮舉過去,從而花費大量時間。
但對于驗證者,只需將收到的答案,計算一次就可判斷對錯,因此可輕易校驗。
這就是 PoW(Proof-of-Work),用來證明對方投入工作的方法。
當然,上面的例子太困難了,而且答案可以重復使用,所以還需改進。例如:
#!cpp
MD5("問題" + X) == "0000......"
我們只要求散列結果的前幾位是 0 就可以。這樣位數越小,答案就越容易找到。同時增加一個鹽值,讓答案不能重復使用。
事實上,比特幣就用到了類似的方式,使用了 SHA-256 作為散列函數。這樣只能窮舉,無法用更快的方法投機取巧,體現了挖礦工作的價值。
用散列函數實現的 PoW,就叫 Hashcash。
Hashcash 早不是新鮮事,曾經在反垃圾郵件中就已使用。
例如用戶寫完郵件時,客戶端將「收件地址 + 郵件內容」作為 Salt,然后計算符合條件的答案:
#!cpp
Hash(X, Salt) == "000000..."
最后將找到的 X 附加在郵件中并發送。服務端收到后,即可鑒定發送這封郵件,是否花費了計算工作。
對于正常用戶來說,額外的幾秒并不影響使用;但對于制造垃圾郵件的人,就大幅增加了成本。
傳統策略,大多通過 IP、賬號等限制。攻擊者可以用大量的馬甲和代理,來繞過這些限制。
而使用了 PoW,就把瓶頸限制在硬件上 —— 計算有多快,操作才能多快。
同樣的,Hashcash 也能用于 Web。例如論壇,可在發帖時計算:
#!cpp
Hash(X, 帖子內容) == "000000..."
不過,不同于郵件客戶端可在后臺自動計算,發帖時如果卡上好幾秒,將會大幅降低用戶體驗。
因此不能選擇 帖子內容、標題 等這些用戶輸入作為鹽值。而是用傳統驗證碼的方式,后端下發一個隨機數。
前端使用這個隨機數作為鹽值 —— 這樣頁面打開時,就可以開始計算了。
#!cpp
# 后端 - 分配
session["pow_code"] = rand()
# 前端 - 挖礦
while Hash(X, pow_code) == "000000..."
X = X + 1
end
我們選擇一個適中的難度,例如 10 秒。通過多線程,還可以更快的完成計算任務,同時不影響用戶體驗。
正常情況下,用戶發帖前就已計算完成。提交時,將其附帶上。
如果提交時還未算出,則等待計算完成。(發帖太快,有灌水嫌疑)
#!cpp
# 前端 - 提交
wait X
submit(..., X)
# 后端 - 校驗
if Hash(X, session["pow_code"]) == "000000..."
ok
else
fail
end
這樣,就實現了一個「測試機器算力」的驗證碼。
目前已有提供 hashcash 第三方驗證的網站,例如 hashcash.io。
當然在 Web 中使用,性能也是一大問題。如果 10 秒的腳本計算,用本地程序只需 1 秒,那攻擊者就可以使用本地版的外掛了。
好在如今有 asm.js,可接近原生性能;對于較老的瀏覽器,也可以使用 Flash 作后補。在上一篇文章 0x08 節 中已詳細講解。
如果算力實在不夠,也可以使用后備方案 —— 傳統圖形驗證碼。
這樣,高性能用戶可享受更好的體驗,低性能用戶也能保障基本功能。
這也算是鼓勵大家使用現代瀏覽器吧:)
不過,語言上的性能差距還是有限的,外掛不會糾結于此,而是使用更強力的武器 —— GPU。
Hashcash 的本質就是跑 hash,這是 GPU 最擅長的。例如著名的 oclHashcat,和 CPU 完全不在一個數量級。
對抗硬件的并行計算,大致有如下方案和思路:
前 3 個在上一篇文章 0x09 節 提到了,下面討論一些不同的。
如果我們也能在 Web 中調用顯卡計算,那 GPU 版的外掛就毫無優勢了。
不過,這個想法似乎有些遙遠。盡管目前主流瀏覽器都支持 WebGL,但都只局限于渲染加速上,并未提供通用計算接口。
當然,也可以通過一些 hack 的方式,例如曾有人嘗試用 WebGL 挖比特幣,但效率并不高。
如果未來 WebCL 成為標準,或許還能考慮。
上回討論慢加密時,曾提到為什么要性能優化。因為自己創造加密算法是不推薦的,所以得優化現有的算法。
不過,相比賬號安全,驗證碼的要求則低得多,而且隨時可以更換算法,因此不妨自己來創造一個。
自創的加密算法,強度顯然沒有保障。但我們可以從「隱蔽性」上著手 —— 將代碼混淆到難以讀懂,這時,考驗對方的則是逆向能力了。
這和之前寫的《對抗假人 —— 前后端結合的 WAF》有點類似。不過,如果混淆能做到足夠好,還需要 PoW 機制嗎?
有勝于無。因為瀏覽器指紋、用戶行為等信息,都是可以通過沙盒模擬的。而工作量計算,必須消耗硬件資源,才能得出結果。
因此,使用了 PoW 就能增加攻擊者一些硬件成本。
Hashcash 的原理,決定了它是可以并行計算的。有什么樣的算法,是無法并行計算的?
如果每次計算都依賴上次結果,就無法并行了。例如之前討論的 slowhash:
#!cpp
function slowhash(x)
for i = 0 to 1000000000
x = hash(x)
end
return x
end
這種串行的計算,自然是無法拆分的。但能用到 PoW 上嗎?
顯然不行!因為 PoW 雖然計算困難,但得 容易鑒定。而這種方式,鑒定時也得重復算一遍,成本太大了。
但在現實中,只要設計得當,還是可以嘗試的 —— 我們使用類似 UGC 的模式,讓用戶來貢獻算力!
首先需要一個訪問量較大的網站,在其中悄悄放置一個腳本。利用在線的用戶,來生成問題和答案。
#!cpp
# 隱蔽的腳本
Q = rand()
A = slowhash(Q)
submit(Q, A)
當然,這項工作必須足夠隱蔽,防止被好奇的用戶發現,提交錯誤的答案。
當后端題庫有一定的積累時,就可以使用驗證碼的模式了。用戶訪問時,后端從題庫中隨機抽取一個問題,安排給前端計算:
#!cpp
# 后端 - 分配問題
Q = select_key_from_db()
session["pow_ques"] = Q
# 前端 - 計算問題
A = slowhash(Q)
用戶提交時,后端無需任何計算,直接通過查表,即可判斷答案是否正確:
#!cpp
# 前端 - 提交
submit(..., A)
# 后端 - 鑒定
Q = session["pow_ques"]
if A == db[Q]
ok
else
fail
end
使用預先計算的方式,避免了繁重的鑒定工作。同時,把計算交給用戶來完成,可大幅節省硬件成本。
當然,這種模式還有很多需要考慮的地方,這里只是介紹下基本思路,以后再詳細討論。
相比 hashcash 題解時間有一定的隨機性,slowhash 的時間是固定的,因此難度更可控。
因為 Hashcash 比較簡單,所以這里演示一個 md5 版的,使用 asm.js 和 flash 實現,并對算法做了一定優化。
https://github.com/EtherDream/proof-of-work-hashcash
如果想看詳細的算力速度,可以查看這個 Demo:
http://www.etherdream.com/FunnyScript/hashcash/js/test.html
看起來好像不慢,不過對比 GPU 的速度 就相形見絀了。所以,使用經典算法的 Hashcash,簡直就是不堪一擊的。
至于串行模式的 PoW,涉及到很多策略和數據積累,本文就演示了,下回單獨討論。
最后來對比下,算力驗證和傳統圖形驗證的區別。
驗證方式 | 驗證對象 | 用戶體驗 | 攔截假人 | |
---|---|---|---|---|
傳統驗證 | 圖像識別 | 人腦 | 需要交互 | 部分攔截 |
算力驗證 | 問題解答 | 電腦 | 無感知 | 無法攔截 |
論效果,當然還是傳統的圖形驗證更好,但這是以犧牲用戶體驗為代價的。
硬件在不斷的發展,識圖軟件會越來越強大。而人腦始終是有限的,優勢會越來越小,最終導致驗證碼越來越復雜。
但是算力驗證則不同。硬件的發展,也會帶動瀏覽器的算力提升,最終只需將問題難度調高即可。
當然,安全防御涉及的領域越多越好。每一個方案都不是無敵的,都只是為了增加一些攻擊成本而已。
所以算力驗證,結合傳統防御方案,才能出發揮價值。