Pwnhub 是一个面向安全研究人员的CTF对战平台,更官方一点的解释就是一个以各种安全技术为内容的竞赛平台。经过我们团队数个月的酝酿终于上线了。上线伊始,我出了一道Web题目,名字叫Classroom。
打开目标( http://54.223.46.206:8003/ )可以看到,一个登录页面。据我长期观察,50%的CTF题目打开都是一个登陆页面,而其中又有60%的可以用各种方式拿到源码。
虽然上面两个百分比是我编的,但这种题目找到源码的概率比较大。先打开burp看看数据包:
一共四个包,第一个包是一个302跳转,跳转到第二个包,也就是登录页面;第二个包就是登录页面,其中包含了一个ico(图标)和一个js;第三个包是js;第四个包是ico。
上图是js的数据包,观察一下,发现了两个信息:
第一个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://cloud.tencent.com/developer/article/1717593 再想到Django自身也出现过的漏洞 https://bugzilla.redhat.com/show_bug.cgi?id=CVE-2009-2659 ,于是测试一下:
果然是存在任意文件读取漏洞的。
Linux系统中,一切都是文件。所以说,文件读取漏洞将能发挥很大作用。
如../../proc/self/fd/5 请求发现是log日志的文件描述符:
而正常情况下日志文件是不可读的(我将其权限设置为root:700),所以这也是一个读取日志文件的方法。如果你后续思路断了,可以尝试读读日志文件,看看别人的思路。
不过这不是正解。这里找到文件读取漏洞,很显然下一步就是看看敏感文件和源代码,中间步骤我就不多说了,读取源代码的时候发现不能读取.py等后缀的文件。
这里自然会想到.pyc文件,.pyc是python的字节码文件,python3.5.2的字节码文件在__pycache__/*.cpython-35.pyc
中。然后看一下Django的文件结构:
其中,Django的逻辑代码全部在views.py里,数据库模型在models.py里。那么,下载这两个文件的字节码文件即可:
在burp里选中那一大段二进制内容,右键save to file即可保存到文件。
使用 https://github.com/rocky/python-uncompyle6 可以反编译python3的字节码文件,得到如下结果:
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:
而如果密码中不包含c这个字符,那么语句stu = models.Student.objects.filter(**data).first()
是查询不到任何结果的,下面的if语句if not stu or stu.passkey != data['passkey']:
也就不会再执行到data['passkey']
的位置,此时返回403:
所以,我们可以通过contains语句,一个字符一个字符将我们需要的字段跑出来。通过判断状态码,我们就可以构造一个具有“盲注”特点的POC。
这个题最开始其实是有个小坑的。熟悉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表。
其实和操作符非常类似,关联表查询也是使用两个下划线来分隔字段:
上述请求返回500,说明Group表的secret字段中包含c这个字符。剩下的就和之前的操作一样了,不多说。
因为知道flag的格式是pwnhub{flag:xxx},所以只需要简单写个脚本,使用{"group__secret__regex":"pwnhub{flag:.*}"}
一个个字符将.*
的内容跑出来即可: