這些看似隨機的數字真的是隨機產生的嗎?
關於這個漏洞
大約半年前,我發現Pubu電子書城使用了一個過於精簡的亂數產生器來生成書頁編號。 這導致攻擊者能夠輕易預測這些書頁的編號,並且在沒有付錢購買的情況下將電子書下載下來。 在Pubu電子書城上架的電子書大約有三十萬冊,而它們的售價合計約為新台幣四千三百萬元。 只要利用這個漏洞,這些電子書都能被隨意下載。
在我發現這個漏洞之後,我在2023年3月25日向HITCON ZeroDay回報了這個漏洞的詳細內容,而HITCON ZeroDay也在四月時向Pubu通報了兩次,不過Pubu對於這個漏洞回報並無回應。 截至今日,這個漏洞仍然沒被修補。 目前這個漏洞的詳細內容以及攻擊程式碼已經公開,說不定現在已經有人在利用這個漏洞竊取所有的電子書。 但我也必須強調,使用這個漏洞可能會違反法律。
我寫這篇文章的主要目的在於提醒Pubu這個漏洞的嚴重性(雖然早已提醒過),也希望他們可以盡早進行漏洞修補,畢竟如果這些電子書公開流傳於網路上,受創的除了Pubu之外還有創作這些書籍的作家、出版業等等。
在這篇文章中,我會先簡介竊取Pubu網站上的電子書的方法,接著詳述這個漏洞的內容以及我如何發現這個漏洞。 除此之外,我也會提供我的攻擊程式碼,並提出一些可能的防禦方法。
攻擊者如何竊取書籍?
當一位使用者在Pubu網站上閱讀一本書時,Pubu會使用這本書的書頁編號來向API伺服器請求這些書頁的檔名。 取得這些檔名之後,就能透過CloudFront(一個由Amazon提供的CDN服務)來將書籍內容下載下來以供使用者閱讀使用。
這個流程看起來很正常,但其實存在兩個嚴重的問題。 首先,雖然這些書頁編號看起來又長又隨機(像是970338046443724931跟472388846439747981),但它們其實出乎意料的短又容易被預測。 再者,Pubu的API伺服器在權限控管上為完全開放,任何人(包含沒買書的人)都可以對它提出請求。
如此一來,只要攻擊者知道任意一頁的書頁編號,他就可以預測其他的書頁編號並把整本書下載下來。
不過攻擊者要怎麼知道某一頁的書頁編號呢? (不)幸運的是,Pubu提供了試閱的功能,讓使用者可以看到一本書的其中幾頁,而攻擊者也可以利用這個功能來取得一些書頁編號。
簡而言之,攻擊者可以透過下面三個步驟來竊取一本書:
- 試閱一本書並取得一些書頁編號。
- 預測這本書其他書頁的編號。
- 透過API伺服器取得書頁的檔案名稱並把書下載下來。
攻擊的細節
在我們知道攻擊者如何竊取一本書之後,現在我們可以來看看每個攻擊步驟的細節。
試閱提供的內容
攻擊者的第一個步驟為取得一本書的試閱內容,雖然試閱只會提供短短幾頁的預覽,但這些資訊已經足夠將整本書下載下來。 如果我們在試閱書籍時紀錄當時的網路流量,我們會在瀏覽器的傳輸內容中找到API伺服器的網址:
https://www.pubu.com.tw/api/flex/3.0/book/[book-id]?referralType=RETAIL_TRY
當瀏覽器透過這個網址與API伺服器提出試閱請求後,會得到以下這段JSON格式的資料,其中包含試閱的書頁編號以及一些書籍的資訊。 以下資料為試閱編號365589這本書時所得到的內容。
{"book": {"pages": [{"pageNumber": 15,"id": "854173581359938562","version": 0},{"pageNumber": 16,"id": "454163985352487572","version": 0},{"pageNumber": 17,"id": "357113981320723572","version": 0}],"totalPage": 32,"hasPermission": false,"documentId": 300923,"title": "工商時報 2023年6月21日","storeId": 568803,"direction": 1},"isLogged": true,"key": "26C071CB680DB1A1C190375723D128B7"}
這段試閱資料中最重要的部分有三個欄位,分別為:
- id:書頁編號
- totalPage:這本書的頁數
- documentId:文件編號(之後會用到)
從這段資料中我們只能找到試閱書頁的編號,而不包含書頁的檔案內容,這是因為書頁的內容是在取得試閱資訊後再透過其他網址取得。
在傳輸完畢試閱資料後,瀏覽器會向API伺服器傳輸更多請求,這些請求的網址如下:
https://www.pubu.com.tw/api/flex/3.0/page/854173581359938562/1c61eeb88aaf54eaaea283f722fa892f/reader/jpg?productId=365589https://www.pubu.com.tw/api/flex/3.0/page/454163985352487572/773c20a4b3703dfd937fb0c5777c27c2/reader/jpg?productId=365589https://www.pubu.com.tw/api/flex/3.0/page/357113981320723572/56236a4fafa7fc968633dde537acdb74/reader/jpg?productId=365589
而這些請求得到的回覆為:
https://d3lcxj447n2nux.cloudfront.net/docs/300923/ZcCZcD.jpghttps://d24p424bn3x9og.cloudfront.net/docs/300923/qJhMcf.jpghttps://d3rhh0yz0hi1gj.cloudfront.net/docs/300923/OFB9XS.jpg
回覆中的這些網址為書頁檔案存放於CloudFront上的網址,只要使用這些網址就可以取得實際的書頁內容。
如果我們在不傳送任何cookies的情況下(例如隱私保護模式)傳送這些請求,我們還是會得到相同的回覆內容。 這代表說API伺服器並不會進行使用者的登入驗證,而我們只要能夠製造出這些請求的網址就能獲取書頁的實際內容。
但要如何才能製造出這些網址呢? 這些請求的網址看起來又長又複雜,感覺上好像不可能被偽造。
在我們放棄之前,我們先來分析一下這些請求網址中的成分。 在這些網址 中首先有一段很長的數字,像是854173581359938562,如果把它拿去跟試閱資料中的內容進行比較,會發現這些數字其實就是書頁編號。 也就是說,我們只要取得書頁編號就能建構出請求網址這部分的內容。
接下來我們來看書頁編號後面那串以十六進位進行編碼的文字。 這些數字看起來相當複雜,但如果我們對網頁的JavaScript程式碼進行分析,我們會發現這段文字其實是書籍與書頁資訊的MD5雜湊值。 所以說如果我們要產生這段文字,只需要......等一下,如果我們隨便用一個數字1來取代這段文字,也能取得一樣的回覆內容嗎?
沒錯,除了書頁編號之外的欄位一點都不重要,包含最後面的productId也可以被數字1所取代。 如此一來,我們只要得到書頁編號,就能建造出正確的請求,也就能夠得到書頁的實際內容。 我們已經知道試閱內容中的書頁編號,但是其他未提供試閱的書頁我們仍然不知道它們的編號。 因此接下來我們必須想辦法產生這些又長又隨機的書頁編號。(但它們真的是隨機產生的嗎?)
計算出書頁編號
這一步是所有攻擊步驟中最困難的部分。 由於試閱內容中的書籍編號為API伺服器提供給我們的資訊,因此我們沒辦法透過分析網頁JavaScript程式碼來找出這些數字是如何計算出來的。 但是別擔心,我們先回過頭來看看我們已知的這些書頁編號。
15: 85417358135993856216: 45416398535248757217: 357113981320723572
這些數字看起來不具規則,但好像又有些規則在裡面。 像是它們都是以2結尾,而第二位數字都是5。
除了這些小巧合之外,這些書頁編號有另一個更加有趣的現象: 如果我們重新整理試閱內容,會發現我們取得的書頁編號跟之前取得的數字完全不同。 以下是我重新整理十五次之後取得的第十五頁的書頁編號,我將每一位數分開來以便查看哪些位數會發生改變,而哪些位數總是具有同樣的數值。
A B C D E F G H I J K L M N O P Q R8 5 4 1 7 3 5 8 1 3 5 9 9 3 8 5 6 27 5 1 1 3 3 8 8 0 3 8 9 0 3 6 5 3 27 5 4 1 5 3 9 8 5 3 5 9 1 3 3 5 0 27 5 1 1 5 3 0 8 7 3 1 9 3 3 5 5 3 20 5 9 1 3 3 9 8 9 3 1 9 4 3 8 5 5 26 5 8 1 2 3 1 8 9 3 0 9 1 3 8 5 2 28 5 9 1 1 3 1 8 1 3 3 9 7 3 4 5 3 23 5 4 1 6 3 6 8 9 3 1 9 5 3 0 5 7 20 5 8 1 9 3 5 8 6 3 9 9 4 3 9 5 3 24 5 0 1 6 3 0 8 6 3 3 9 2 3 3 5 5 26 5 4 1 5 3 5 8 2 3 5 9 6 3 6 5 7 26 5 3 1 4 3 5 8 4 3 8 9 2 3 5 5 8 21 5 7 1 6 3 4 8 2 3 5 9 9 3 4 5 8 29 5 0 1 3 3 1 8 5 3 0 9 5 3 9 5 1 21 5 4 1 4 3 6 8 6 3 9 9 5 3 2 5 7 2
在B、D、F等等這些欄位中的數字永遠都是相同的數值,而其他欄位的數字則是會隨機變化。 這些隨機變化的數字也許有某種特定的變化規則,但如果我們將它們全部替換為零(或是其他數字)並拿去向API伺服器提出請求,會發現得到的結果相同。 這代表說在A、C、E等等這些欄位中的數字並不重要,只要我們能夠取得B、D、F等等這些欄位中的數字就行了。
得到這個資訊之後,我們來將原先試閱資訊中的書頁編號去掉其中不重要的欄位,看看會得到什麼樣的結果。
B D F H J L N P R15: 5 1 3 8 3 9 3 5 216: 5 1 3 8 3 2 8 5 217: 5 1 3 8 3 0 2 5 2
去掉不重要的數字之後,這三頁的編號只有L與N欄位中的數字有所不同。 也許這是因為書頁編號會隨著頁數增加而跟著增加,導致L與N欄位中的數字發生變化。 但我們仍需更多資訊才能完全釐清這其中的道理。
我們可以去試閱更多的書並透過更多的書籍編號來找出它們變化的規則,但我們也可以直接對API伺服器進行測試,看看會不會得到一些有用的資訊。 舉例來說,我們可以傳送50個零當作書頁編號來看看API伺服器的回覆:
這段錯誤訊息相當有趣,雖然我們傳送的是50個零,但得到的回覆卻是25個六。 這樣的結果跟我們一開始發現的有半數欄位為不重要的數字結果相符,這能夠解釋為何回覆的內容為25個數字,然而為何零會變成六仍無法解釋。
我們也可以傳送50個一、二、三等等來看看API伺服器會做出什麼回覆。 以下是其他數字得到的回覆:
請求 => 錯誤訊息--------------------50 * "0" => 25 * "6"50 * "1" => 25 * "1"50 * "2" => 25 * "4"50 * "3" => "the pageId value must greater than zero"50 * "4" => 25 * "8"50 * "5" => 25 * "9"50 * "6" => 25 * "2"50 * "7" => 25 * "5"50 * "8" => 25 * "7"50 * "9" => 25 * "3"
從這個結果可以看出API伺服器會將我們傳送的數字"翻譯"成其他的數字,像是0被翻譯成6、4被翻譯成8等等。 其中由於數字3被翻譯成0,等同於我們向API伺服器請求書頁編號為零的這筆資料,因此錯誤訊息變成"書頁編號必須大於零"。
所以說如果我們想要得到98765432109876543210這樣的錯誤訊息,只要將這 段數字翻譯回去變成54807296135480729613之後,再插入其他不重要的欄位的數字就能得到我們預期的錯誤訊息了,對吧?
並沒有。 我們得到的錯誤訊息是10987654321098765432,而非98765432109876543210。 不過我們也不是完全猜錯,這段錯誤訊息中還是有9876543210這串數字,只不過位置發生了變動。 也許API伺服器會對書頁編號進行某種位置的調換? 為了驗證這個猜想,我們可以用十九個1與一個2進行請求,並把2放置於不同的位置看看最後它會落到哪裡。
A B C D E F G H I J K L M N O P Q R S T A B C D E F G H I J K L M N O P Q R S T2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 11 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 11 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 11 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 11 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 11 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 11 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 11 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 11 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 11 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 11 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 11 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 11 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 11 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 11 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 11 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 11 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 11 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 1 => 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 21 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 1 => 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 11 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 => 1 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
透過2改變的位置我們可以看出API伺服器進行的位置調換其實很單 純,它只會將結尾的兩個數字搬到最前面。 換言之,只要我們將我們原本的請求54807296135480729613其中最前面的兩個數字搬到最後面,也就是80729613548072961354,我們就能得到預期中的錯誤訊息了。 我們來驗證看看。
這次總算是猜對了!
簡單整理一下目前得到的資訊,API伺服器會對我們輸入的內容進行三個變換:
- 移除A、C、E等等這些不重要的欄位中的數字
- 對剩下的數字進行翻譯
- 將結尾的兩個數字搬到最前面
如果我們將試閱內容中的書頁編號進行這些變換,會得到以下的結果。
15: 854173581359938562 => 94910703016: 454163985352487572 => 94910704717: 357113981320723572 => 949107064
現在這些數字看起來合理多了,當頁碼增加1的時候,這些書頁編號會增加17。 不過還是有些小細節有待釐清,像是為何是增加17而不是其他數字?如果我們只把書頁編號加1而不是加17就拿去對API伺服器進行請求會發生什麼事?
不過這些小細節其實一點都不重要,因為我們已經知道如何預測其他書頁的編號了(想要知道這些細節發生了什麼事的人可以看看附錄章節)。 舉例來說,如果要得到第18頁的編號,只要將第17頁的編號加上17就行了; 想要得到第14頁的編號的話就把第15頁的編號減去17。
我們來實際計算看看第18頁的編號。 首先把第十七頁的編號加上17,得到949107081,接著將前面兩位數字搬到後面,也就是910708194。 將這串數字進行翻譯,得到513834152,最後再插入不重要的數字,變成050103080304010502。 如果將這串數字拿去對API伺服器進行請求,會得到以下這個網址,也就是被重新排列之後的第18頁的內容。
https://duyldfll42se2.cloudfront.net/docs/300923/VvOusf.jpg
在圖片中可以看到B4這個頁碼,而在試閱時可以看到第17頁的頁碼為B3,這也證明我們得到的圖片的確是第18頁的內容。
所以我們只要透過加17或減17的方式就可以預測出其他書頁的編號。 不過有的時候一本書的書頁會穿插進其他書的內容,這個時候我們必須檢查我們得到的結果是否為同一本書的內容。 這樣的檢查相當容易,這是因為API伺服器回覆的網址中具有documentId的欄位,我們只要確認這個documentId與當初試閱時得到的數字相符即可。 以第18頁的網址為例,這個網址中的documentId為300923,這跟試閱資訊中的數字相符。
到這一步為止,我們已經取得了一本書中所有的書頁檔案,但是這些檔案為重新排列過的圖片檔,因此我們還需要將它們排列回原本的樣子。
重新排列圖檔
當我們將書頁檔案下載下來之後,得到的會是如下圖右側的結果,圖片像是被重新洗牌一般以奇怪的方式排列。
我們必須將圖片以原先的方式排列才能將書頁復原回原本的樣貌,但我們首先必須找出這些圖片的正確排列方式。
如果我們使用Chrome瀏覽器的檢查工具找出傳送請求給API伺服器與CloudFront的程式碼,會發現主要都是一個名為canvasReader.js的JavaScript檔案在進行這些動作。 在這份程式碼之中可以找到許多處理這些書頁有關的函式,在這之中decode跟newDecode這兩個函式會負責將圖片排列回原始樣貌。 這兩個函式其實也沒什麼特別之處,只要我們照著程式碼的步驟對圖片進行操作就能還原出原本的書頁。 至此,我們已經將所有的書頁下載下來也還原成正常的書頁, 大功告成。
攻擊程式碼
為了證明這個攻擊的可行性,我用Python撰寫了一份攻擊程式碼,並且公開於GitHub上。 由於目前這個漏洞並未被修復,現在還是能夠使用這份程式碼來下載Pubu上的任何書籍。 但在執行我的程式碼之前,還是要再次提醒這麼做是違法行為。 我公開這份程式碼只是為了證明這個漏洞不只是紙上談兵,並且也提供關於這個漏洞的更多細節。
回報與防禦方式
在我發現這個漏洞之後就有透過HITCON ZeroDay向Pubu進行回報,而我自己也有試著跟Pubu聯絡請它們修復漏洞。 不過目前Pubu依然沒有正面回應也沒有對漏洞進行修復。
如果我是Pubu的工程師,我應該會做出以下這些調整:
- 調整API伺服器的使用權限,只允許有購書的使用者查詢相關資料。
- 在CloudFront進行使用者驗證,只允許有購買書籍的人下載資料。
- 將目前CloudFront上的檔案進行改名,並且使用安全的偽亂數產生器來產生新的檔名。
希望Pubu會盡速修復這個漏洞。
附錄:真正的書頁編號
我們已經知道當頁碼增加1的時候書頁編號會增加17,不過為何是17這個數字仍不清楚。 一個可能的原因是我們看到的這些書頁編號是"真正的書頁編號"乘上17的結果。 (為了區別我們之前看到的書頁編號與"真正的書頁編號",接下來會將真正的書頁編號稱為"書頁編號",而之前看到的書頁編號稱為"假編號"。) 在乘上17之後,當書頁編號每增加1,我們看到的假編號就會增加17。 我會做出這樣的猜測是因為仿射密碼也是以類似的方式運作。
在仿射密碼中,進行乘法運算之後還會加上或減去一個數字,而我們看到的假編號可能也有被加上或減去某個數字,只是我們目前還無法得知這個數字為何。 為了得出這個數字,我們必須再回過頭來使用API伺服器的錯誤訊息來得出更多資訊。
還記得在找出數字翻譯規則時所出現的錯誤訊息,"書頁編號必須大於零"嗎? 只要我們傳送過去的書頁編號小於或等於0時就會出現這樣的訊息,利用這個錯誤訊息我們就有辦法找出這個未知的數字。
當我們使用假編號000到021進行請求時,API伺服器都會出現錯誤訊息,但是當我們使用022進行請求時,得到的回 覆會變成HTTP錯誤代碼403。
從這樣的結果我們可以看出書頁編號1經過轉換後會變成假編號022,也就是說書頁編號與假編號之間的轉換為乘上17後再加上5,所以5就是最後這個未知數字的數值。
如果你沒被這個小小的實驗說服的話,請繼續看下去。
通常來說,API伺服器接收的數字是書頁編號經過轉換後的假編號,但我發現在輸入的數值小於10000的情況下,API伺服器會把輸入的內容當成真正的書頁編號對待。 這也代表如果我們能夠使用真正的書頁編號與轉換後的假編號對API伺服器進行請求,而回覆結果一樣的話就代表我們的轉換方式是正確的。
舉例來說,我們可以:
- 先用真正的書頁編號3414取得API伺服器的回覆。
- 將3414轉換為假編號後再次向API伺服器進行請求,看回覆是否相同。
以下是以真正的書頁編號3414進行請求得到的結果:
接著來將3414轉換為假編號。 首先乘以17並加上5,得到58043。 接著將開頭的兩個數字(58)搬到最後面,變成04358。 最後將數字進行翻譯並插入不重要的數字0,得到0302090704。 我們來比較看看結果是否相同。
從API伺服器的回覆可以看出不管是用書頁編號或是假編號得到的結果都相同,這也證明了以上得到的轉換方式是正確的轉換方式。