Pwnhub Web题Classroom题解与分析

Pwnhub 是一个面向安全研究人员的CTF对战平台,更官方一点的解释就是一个以各种安全技术为内容的竞赛平台。经过我们团队数个月的酝酿终于上线了。上线伊始,我出了一道Web题目,名字叫Classroom。

0x01 寻找源码

打开目标( http://54.223.46.206:8003/ )可以看到,一个登录页面。据我长期观察,50%的CTF题目打开都是一个登陆页面,而其中又有60%的可以用各种方式拿到源码。

虽然上面两个百分比是我编的,但这种题目找到源码的概率比较大。先打开burp看看数据包:

14809540240989.jpg

一共四个包,第一个包是一个302跳转,跳转到第二个包,也就是登录页面;第二个包就是登录页面,其中包含了一个ico(图标)和一个js;第三个包是js;第四个包是ico。

上图是js的数据包,观察一下,发现了两个信息:

  1. Server: gunicorn/19.6.0 Django/1.10.3 CPython/3.5.2
  2. Content-Type: text/plain

第一个Server头表明了这个网站是用Python 3.5.2开发,基于Django 1.10.3框架,使用gunicorn 19.6.0部署。

第二个Content-Type头很不一般,值为text/plain表明了它并不是一个js文件。

可他明明就是js文件呀?

在正常环境下(nginx或apache等中间件),js的Content-Type应该是application/javascript,再不济也应该是text/javascript,怎么会是text/plain?

可以猜测这里的静态文件并非自动分发的静态文件,可能是用户自己编写的静态文件逻辑。参考一下这个漏洞 https://www.leavesongs.com/PENETRATION/arbitrary-files-read-via-static-requests.html 再想到Django自身也出现过的漏洞 https://bugzilla.redhat.com/show_bug.cgi?id=CVE-2009-2659 ,于是测试一下:

14809549122078.jpg

果然是存在任意文件读取漏洞的。

文件读取能做什么事

Linux系统中,一切都是文件。所以说,文件读取漏洞将能发挥很大作用。

如../../proc/self/fd/5 请求发现是log日志的文件描述符:

14809599777259.jpg

而正常情况下日志文件是不可读的(我将其权限设置为root:700),所以这也是一个读取日志文件的方法。如果你后续思路断了,可以尝试读读日志文件,看看别人的思路。

不过这不是正解。这里找到文件读取漏洞,很显然下一步就是看看敏感文件和源代码,中间步骤我就不多说了,读取源代码的时候发现不能读取.py等后缀的文件。

这里自然会想到.pyc文件,.pyc是python的字节码文件,python3.5.2的字节码文件在__pycache__/*.cpython-35.pyc中。然后看一下Django的文件结构:

14809612445787.jpg

其中,Django的逻辑代码全部在views.py里,数据库模型在models.py里。那么,下载这两个文件的字节码文件即可:

14809613059891.jpg

在burp里选中那一大段二进制内容,右键save to file即可保存到文件。

使用 https://github.com/rocky/python-uncompyle6 可以反编译python3的字节码文件,得到如下结果:

14809617099099.jpg

Python代码审计

views.py代码不多,大概看一下最关键的登录位置的源码:

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)

可见,这里将从POST Body中获取的内容用json解码以后,直接传给了django orm的filter方法:models.Student.objects.filter(**data).first()

这里造成一个Django ORM的注入,这个注入和ThinkPHP的《ThinkPHP架构设计不合理极易导致SQL注入》类似,也和Mongodb的注入《Mongodb注入攻击》类似。

(关于ORM注入,我在我的小密圈“代码审计”中有文章详细说明,感兴趣的可以去我的圈子转转,圈子二维码附在文章后)

这个注入的核心就是,我们可以控制filter方法的参数名,而Django中,SQL语句的符号全部是通过参数名后面的一些关键词实现的。举个最简单的例子,查询“在User表里查询age大于30的所有用户”,这里可以写作User.objects.filter(age__gt=30).all()

所以,这里我们控制了参数名,就等于可以控制一些SQL语句的符号了。本题中,主要可以用到如下一些符号:

name__contains='abc' -> name LIKE '%abc%' -> 包含关键词abc的name
name__startswith='abc' -> name LIKE 'abc%' -> 以关键词abc开头的name
name__regex='abc' -> name REGEXP '^abc$' -> 匹配正则表达式^abc$的name

这里,我们可以传入{"passkey__contains":"a"},只要密码里包含‘a’这个字母就可以匹配成功,造成注入。

但我们看到后面,后面还有一个判断stu.passkey != data['passkey'],这个比较自然是绕不过去的,怎么办?

虽然绕不过去,但考虑一下,如果数据包中不含有“passkey”这个键的时候,此时Python是会抛出一个KeyError异常的,在HTTP中就体现为status_code==500:

14809629196681.jpg

而如果密码中不包含c这个字符,那么语句stu = models.Student.objects.filter(**data).first()是查询不到任何结果的,下面的if语句if not stu or stu.passkey != data['passkey']:也就不会再执行到data['passkey']的位置,此时返回403:

14809629427606.jpg

所以,我们可以通过contains语句,一个字符一个字符将我们需要的字段跑出来。通过判断状态码,我们就可以构造一个具有“盲注”特点的POC。

Flag藏在哪里?

这个题最开始其实是有个小坑的。熟悉SQLite的同学应该知道,SQLite数据库的like查询是大小写不敏感的。而上述的contains语句,实际上最后执行的是passkey like '%xxx%',此时如果flag中混搭大小写字母,contains操作符是分辨不了的。

所以,这里最建议使用的方法是regex操作符,使用方法和contains类似。通过regex正则操作符,甚至还可以判断出目标的长度、字符范围,但实际上本题中是不太需要的。

通过一番折腾,很多选手发现注入出来的三个passkey并没有什么卵用,登录进去以后也没有任何可疑信息。

此时就应该再读读源码了,看看models.py内容:

# uncompyle6 version 2.9.7
# Python bytecode 3.5 (3350)
# Decompiled from: Python 3.5.2 (default, Oct 11 2016, 05:05:28)
# [GCC 4.2.1 Compatible Apple LLVM 8.0.0 (clang-800.0.38)]
# Embedded file name: /www/students/models.py
# Compiled at: 2016-11-26 03:04:46
# Size of source mod 2**32: 1033 bytes
from django.db import models

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 Meta:
        verbose_name = '学生'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name

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)

    class Meta:
        verbose_name = '班级'
        verbose_name_plural = verbose_name

    def __str__(self):
        return self.name
# okay decompiling /Users/shiyu/tmp/models.pyc

也比较简单,只有两个表。其中,Group表有一个secret字段非常可疑,所以我们可以试试通过注入来查查这个字段中的信息。

这里就涉及到Django的另一个知识:关联表查询。我们看到Student表中有一个ForeignKey字段,指向的就是Group表。

其实和操作符非常类似,关联表查询也是使用两个下划线来分隔字段:

14809638179069.jpg

上述请求返回500,说明Group表的secret字段中包含c这个字符。剩下的就和之前的操作一样了,不多说。

因为知道flag的格式是pwnhub{flag:xxx},所以只需要简单写个脚本,使用{"group__secret__regex":"pwnhub{flag:.*}"}一个个字符将.*的内容跑出来即可:

14809639399168.jpg

“代码审计”圈子二维码(微信):

WechatIMG8.jpeg

评论

企业咨询 回复

感觉懂技术的都好牛掰,只能粗浅的看懂一些

wupco 回复

不得不膜一波,思路很清新,之前只知道Django的authorkey泄露的洞,所以根本想不到可以跨目录读取文件。很期待下一次,打开思路。

neargle 回复

这道题我也琢磨了一段时间,想到师傅出的题,应该是有洞能搞到源码的。虽然很在意Django/1.10.3 CPython/3.5.2这个头,但实在知识储备不足。
在没有得到源码的情况下,看到Vuejs的post包是json,倒是大概猜到有这个注入。但是还是没弄出来。
后来把太多时间花在看vuejs的文档上了。
学到了很多姿势。感谢。

FH 回复

那你这个概率蒙的好啊

baiyi 回复

圈子二维码呢....

captcha