部落格>一个关于Pubu电子书城的资安漏洞

一个关于Pubu电子书城的资安漏洞

发表时间:2023-08-19

Pubu电子书城的商标
图片来源:static.pubu.tw

这些看似随机的数字真的是随机产生的吗?

关于这个漏洞

大约半年前,我发现Pubu电子书城使用了一个过于精简的随机数生成器来生成书页编号。 这导致攻击者能够轻易预测这些书页的编号,并且在没有付钱购买的情况下将电子书下载下来。 在Pubu电子书城上架的电子书大约有三十万册,而它们的售价合计约为新台币四千三百万元。 只要利用这个漏洞,这些电子书都能被随意下载。

在我发现这个漏洞之后,我在2023年3月25日向HITCON ZeroDay回报了这个漏洞的详细内容,而HITCON ZeroDay也在四月时向Pubu通报了两次,不过Pubu对于这个漏洞回报并无响应。 截至今日,这个漏洞仍然没被修补。 目前这个漏洞的详细内容以及攻击程序代码已经公开,说不定现在已经有人在利用这个漏洞窃取所有的电子书。 但我也必须强调,使用这个漏洞可能会违反法律。

我写这篇文章的主要目的在于提醒Pubu这个漏洞的严重性(虽然早已提醒过),也希望他们可以尽早进行漏洞修补,毕竟如果这些电子书公开流传于网络上,受创的除了Pubu之外还有创作这些书籍的作家、出版业等等。

在这篇文章中,我会先简介窃取Pubu网站上的电子书的方法,接着详述这个漏洞的内容以及我如何发现这个漏洞。 除此之外,我也会提供我的攻击程序代码,并提出一些可能的防御方法。

攻击者如何窃取书籍?

当一位使用者在Pubu网站上阅读一本书时,Pubu会使用这本书的书页编号来向API服务器请求这些书页的档名。 取得这些档名之后,就能透过CloudFront(一个由Amazon提供的CDN服务)来将书籍内容下载下来以供使用者阅读使用。

在Pubu网站上阅读电子书的流程。
图1:在Pubu网站上阅读电子书的流程。

这个流程看起来很正常,但其实存在两个严重的问题。 首先,虽然这些书页编号看起来又长又随机(像是970338046443724931472388846439747981),但它们其实出乎意料的短又容易被预测。 再者,Pubu的API服务器在权限控管上为完全开放,任何人(包含没买书的人)都可以对它提出请求。

如此一来,只要攻击者知道任意一页的书页编号,他就可以预测其他的书页编号并把整本书下载下来。

不过攻击者要怎么知道某一页的书页编号呢? (不)幸运的是,Pubu提供了试阅的功能,让用户可以看到一本书的其中几页,而攻击者也可以利用这个功能来取得一些书页编号。

简而言之,攻击者可以透过下面三个步骤来窃取一本书:

  1. 试阅一本书并取得一些书页编号。
  2. 预测这本书其他书页的编号。
  3. 透过API服务器取得书页的文件名并把书下载下来。
攻击者预测书页编号并窃取一本书。
图2:攻击者预测书页编号并窃取一本书。

攻击的细节

在我们知道攻击者如何窃取一本书之后,现在我们可以来看看每个攻击步骤的细节。

试阅提供的内容

攻击者的第一个步骤为取得一本书的试阅内容,虽然试阅只会提供短短几页的预览,但这些信息已经足够将整本书下载下来。 如果我们在试阅书籍时纪录当时的网络流量,我们会在浏览器的传输内容中找到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=365589
https://www.pubu.com.tw/api/flex/3.0/page/454163985352487572/773c20a4b3703dfd937fb0c5777c27c2/reader/jpg?productId=365589
https://www.pubu.com.tw/api/flex/3.0/page/357113981320723572/56236a4fafa7fc968633dde537acdb74/reader/jpg?productId=365589

而这些请求得到的回复为:

https://d3lcxj447n2nux.cloudfront.net/docs/300923/ZcCZcD.jpg
https://d24p424bn3x9og.cloudfront.net/docs/300923/qJhMcf.jpg
https://d3rhh0yz0hi1gj.cloudfront.net/docs/300923/OFB9XS.jpg

回复中的这些网址为书页档案存放于CloudFront上的网址,只要使用这些网址就可以取得实际的书页内容。

如果我们在不传送任何cookies的情况下(例如隐私保护模式)传送这些请求,我们还是会得到相同的回复内容。 这代表说API服务器并不会进行使用者的登入验证,而我们只要能够制造出这些请求的网址就能获取书页的实际内容

但要如何才能制造出这些网址呢? 这些请求的网址看起来又长又复杂,感觉上好像不可能被伪造。

在我们放弃之前,我们先来分析一下这些请求网址中的成分。 在这些网址中首先有一段很长的数字,像是854173581359938562,如果把它拿去跟试阅数据中的内容进行比较,会发现这些数字其实就是书页编号。 也就是说,我们只要取得书页编号就能建构出请求网址这部分的内容。

接下来我们来看书页编号后面那串以十六进制进行编码的文字。 这些数字看起来相当复杂,但如果我们对网页的JavaScript程序代码进行分析,我们会发现这段文字其实是书籍与书页信息的MD5哈希值。 所以说如果我们要产生这段文字,只需要......等一下,如果我们随便用一个数字1来取代这段文字,也能取得一样的回复内容吗?

只要得到书页编号就能建造出正确的请求。
图3:只要得到书页编号就能建造出正确的请求。

没错,除了书页编号之外的字段一点都不重要,包含最后面的productId也可以被数字1所取代。 如此一来,我们只要得到书页编号,就能建造出正确的请求,也就能够得到书页的实际内容。 我们已经知道试阅内容中的书页编号,但是其他未提供试阅的书页我们仍然不知道它们的编号。 因此接下来我们必须想办法产生这些又长又随机的书页编号。(但它们真的是随机产生的吗?)

计算出书页编号

这一步是所有攻击步骤中最困难的部分。 由于试阅内容中的书籍编号为API服务器提供给我们的信息,因此我们没办法透过分析网页JavaScript程序代码来找出这些数字是如何计算出来的。 但是别担心,我们先回过头来看看我们已知的这些书页编号。

15: 854173581359938562
16: 454163985352487572
17: 357113981320723572

这些数字看起来不具规则,但好像又有些规则在里面。 像是它们都是以2结尾,而第二位数字都是5

除了这些小巧合之外,这些书页编号有另一个更加有趣的现象: 如果我们重新整理试阅内容,会发现我们取得的书页编号跟之前取得的数字完全不同。 以下是我重新整理十五次之后取得的第十五页的书页编号,我将每一位数分开来以便查看哪些位数会发生改变,而哪些位数总是具有同样的数值。

A B C D E F G H I J K L M N O P Q R
8 5 4 1 7 3 5 8 1 3 5 9 9 3 8 5 6 2
7 5 1 1 3 3 8 8 0 3 8 9 0 3 6 5 3 2
7 5 4 1 5 3 9 8 5 3 5 9 1 3 3 5 0 2
7 5 1 1 5 3 0 8 7 3 1 9 3 3 5 5 3 2
0 5 9 1 3 3 9 8 9 3 1 9 4 3 8 5 5 2
6 5 8 1 2 3 1 8 9 3 0 9 1 3 8 5 2 2
8 5 9 1 1 3 1 8 1 3 3 9 7 3 4 5 3 2
3 5 4 1 6 3 6 8 9 3 1 9 5 3 0 5 7 2
0 5 8 1 9 3 5 8 6 3 9 9 4 3 9 5 3 2
4 5 0 1 6 3 0 8 6 3 3 9 2 3 3 5 5 2
6 5 4 1 5 3 5 8 2 3 5 9 6 3 6 5 7 2
6 5 3 1 4 3 5 8 4 3 8 9 2 3 5 5 8 2
1 5 7 1 6 3 4 8 2 3 5 9 9 3 4 5 8 2
9 5 0 1 3 3 1 8 5 3 0 9 5 3 9 5 1 2
1 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 R
15: 5 1 3 8 3 9 3 5 2
16: 5 1 3 8 3 2 8 5 2
17: 5 1 3 8 3 0 2 5 2

去掉不重要的数字之后,这三页的编号只有L与N字段中的数字有所不同。 也许这是因为书页编号会随着页数增加而跟着增加,导致L与N字段中的数字发生变化。 但我们仍需更多信息才能完全厘清这其中的道理。

我们可以去试阅更多的书并透过更多的书籍编号来找出它们变化的规则,但我们也可以直接对API服务器进行测试,看看会不会得到一些有用的信息。 举例来说,我们可以传送50个零当作书页编号来看看API服务器的回复:

当以50个零作为书页编号时得到的错误讯息。
图4:当以50个零作为书页编号时得到的错误讯息。

这段错误讯息相当有趣,虽然我们传送的是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被翻译成64被翻译成8等等。 其中由于数字3被翻译成0,等同于我们向API服务器请求书页编号为零的这笔数据,因此错误讯息变成"书页编号必须大于零"。

所以说如果我们想要得到98765432109876543210这样的错误讯息,只要将这段数字翻译回去变成54807296135480729613之后,再插入其他不重要的字段的数字就能得到我们预期的错误讯息了,对吧?

错误讯息跟我们预期的不一样。
图5:错误讯息跟我们预期的不一样。

并没有。 我们得到的错误讯息是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 T
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 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 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 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 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 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 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2 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 1 1 1 1 1 2
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 1
1 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,我们就能得到预期中的错误讯息了。 我们来验证看看。

终于得到预期中的错误讯息。
图6:终于得到预期中的错误讯息。

这次总算是猜对了!

简单整理一下目前得到的信息,API服务器会对我们输入的内容进行三个变换:

  1. 移除A、C、E等等这些不重要的字段中的数字
  2. 对剩下的数字进行翻译
  3. 将结尾的两个数字搬到最前面

如果我们将试阅内容中的书页编号进行这些变换,会得到以下的结果。

15: 854173581359938562 => 949107030
16: 454163985352487572 => 949107047
17: 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页的网址为例,这个网址中的documentId300923,这跟试阅信息中的数字相符。

到这一步为止,我们已经取得了一本书中所有的书页档案,但是这些档案为重新排列过的图片文件,因此我们还需要将它们排列回原本的样子。

重新排列图檔

当我们将书页档案下载下来之后,得到的会是如下图右侧的结果,图片像是被重新洗牌一般以奇怪的方式排列。

正常的图片(左图)与重新排列过的图片(右图)。
图7:正常的图片(左图)与重新排列过的图片(右图)。

我们必须将图片以原先的方式排列才能将书页复原回原本的样貌,但我们首先必须找出这些图片的正确排列方式。

如果我们使用Chrome浏览器的检查工具找出传送请求给API服务器与CloudFront的程序代码,会发现主要都是一个名为canvasReader.js的JavaScript档案在进行这些动作。 在这份程序代码之中可以找到许多处理这些书页有关的函式,在这之中decodenewDecode这两个函式会负责将图片排列回原始样貌。 这两个函式其实也没什么特别之处,只要我们照着程序代码的步骤对图片进行操作就能还原出原本的书页。 至此,我们已经将所有的书页下载下来也还原成正常的书页,大功告成。

攻击程序代码

为了证明这个攻击的可行性,我用Python撰写了一份攻击程序代码,并且公开于GitHub上。 由于目前这个漏洞并未被修复,现在还是能够使用这份程序代码来下载Pubu上的任何书籍。 但在执行我的程序代码之前,还是要再次提醒这么做是违法行为。 我公开这份程序代码只是为了证明这个漏洞不只是纸上谈兵,并且也提供关于这个漏洞的更多细节。

回报与防御方式

在我发现这个漏洞之后就有透过HITCON ZeroDay向Pubu进行回报,而我自己也有试着跟Pubu联络请它们修复漏洞。 不过目前Pubu依然没有正面响应也没有对漏洞进行修复。

如果我是Pubu的工程师,我应该会做出以下这些调整:

  1. 调整API服务器的权限,只允许有购书的用户查询相关数据。
  2. CloudFront进行使用者验证,只允许有购买书籍的人下载数据。
  3. 将目前CloudFront上的档案进行改名,并且使用安全的伪随机数生成器来产生新的档名。

希望Pubu会尽速修复这个漏洞。

附录:真正的书页编号

我们已经知道当页码增加1的时候书页编号会增加17,不过为何是17这个数字仍不清楚。 一个可能的原因是我们看到的这些书页编号是"真正的书页编号"乘上17的结果。 (为了区别我们之前看到的书页编号与"真正的书页编号",接下来会将真正的书页编号称为"书页编号",而之前看到的书页编号称为"假编号"。) 在乘上17之后,当书页编号每增加1,我们看到的假编号就会增加17。 我会做出这样的猜测是因为仿射密码也是以类似的方式运作。

在仿射密码中,进行乘法运算之后还会加上或减去一个数字,而我们看到的假编号可能也有被加上或减去某个数字,只是我们目前还无法得知这个数字为何。 为了得出这个数字,我们必须再回过头来使用API服务器的错误讯息来得出更多信息。

还记得在找出数字翻译规则时所出现的错误讯息,"书页编号必须大于零"吗? 只要我们传送过去的书页编号小于或等于0时就会出现这样的讯息,利用这个错误讯息我们就有办法找出这个未知的数字。

当我们使用假编号000021进行请求时,API服务器都会出现错误讯息,但是当我们使用022进行请求时,得到的回复会变成HTTP错误代码403。

假编号021经转换后会变成小于或等于0的书页编号。
图8:假编号021经转换后会变成小于或等于0的书页编号。
假编号022经转换后已变得大于0。
图9:假编号022经转换后已变得大于0。

从这样的结果我们可以看出书页编号1经过转换后会变成假编号022,也就是说书页编号与假编号之间的转换为乘上17后再加上5,所以5就是最后这个未知数字的数值。

如果你没被这个小小的实验说服的话,请继续看下去。

通常来说,API服务器接收的数字是书页编号经过转换后的假编号,但我发现在输入的数值小于10000的情况下,API服务器会把输入的内容当成真正的书页编号对待。 这也代表如果我们能够使用真正的书页编号与转换后的假编号对API服务器进行请求,而回复结果一样的话就代表我们的转换方式是正确的。

举例来说,我们可以:

  1. 先用真正的书页编号3414取得API服务器的回复。
  2. 3414转换为假编号后再次向API服务器进行请求,看回复是否相同。

以下是以真正的书页编号3414进行请求得到的结果:

以书页编号3414得到的书页网址。
图10:以书页编号3414得到的书页网址。

接着来将3414转换为假编号。 首先乘以17并加上5,得到58043。 接着将开头的两个数字(58)搬到最后面,变成04358。 最后将数字进行翻译并插入不重要的数字0,得到0302090704。 我们来比较看看结果是否相同。

以假编号0302090704得到的书页网址。
图11:以假编号0302090704得到的书页网址。

从API服务器的回复可以看出不管是用书页编号或是假编号得到的结果都相同,这也证明了以上得到的转换方式是正确的转换方式。