-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathtest_range.py
More file actions
3337 lines (2903 loc) · 120 KB
/
Copy pathtest_range.py
File metadata and controls
3337 lines (2903 loc) · 120 KB
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""
test_range.py — 本地模拟靶场 + 比赛平台 API
零依赖 (纯标准库),一键启动,用于端到端测试 competition.py + tchkiller
两个组件:
1. 比赛平台 API (端口 9000) — 完全兼容官方 HTTP API
2. 靶场服务 (动态端口) — 25 个漏洞场景,内置 flag
赛题设计:
Level 1 (3 题):
- Login Portal (easy) — 弱口令 admin/admin123
- Info Leak (easy) — robots.txt → 备份文件 → flag
- Command Exec (medium) — ping 命令注入
Level 2 (5 题,解 Level 1 的 2 个 easy 后解锁):
- SQL Injection (medium) — UNION 注入
- File Traversal (medium) — 路径穿越读 flag
- Admin Panel (hard) — 多步: 信息收集 → 登录 → flag
- XSS Guestbook (easy) — 存储型 XSS,注入 <script> 触发 flag
- Vulnerable API (easy) — 版本暴露 + eval 注入
Level 3 (5 题,解 Level 2 的 2 个 easy 后解锁):
- SSTI Template (medium) — Jinja2 模板注入
- File Upload (medium) — 后缀绕过上传 Webshell
- XXE Import (medium) — XML 外部实体读文件
- Tomcat Manager (medium) — 弱口令 + WAR 部署
- Java Deserialize (hard) — 反序列化漏洞 RCE
Level 4 (12 题,VulnDB 验证场景,模拟真实产品指纹和漏洞):
- ThinkPHP 5 RCE (medium) — invokefunction 路由调用执行命令
- Shiro Deser (hard) — rememberMe Cookie 反序列化
- Struts2 S2-045 (medium) — Content-Type OGNL 注入
- Log4Shell (hard) — JNDI lookup 注入 (Header)
- Nacos Auth (easy) — User-Agent 绕过认证
- Jenkins Script (easy) — 未授权 Groovy Script Console
- Solr Velocity (hard) — Velocity 模板注入 RCE
- Drupalgeddon2 (medium) — Form API 远程代码执行
- Spring4Shell (hard) — ClassLoader 数据绑定 RCE
- Fastjson Deser (medium) — @type autoType 绕过 JNDI
- WebLogic SSRF (medium) — UDDI Explorer SSRF
- ActiveMQ Upload (medium) — fileserver PUT 任意文件写入
Usage:
# 启动靶场
python3 test_range.py
python3 test_range.py --port 9000 --token test-token
# 然后在另一个终端运行 competition.py
python3 competition.py --server localhost:9000 --token test-token --workers 1 --dry-run
python3 competition.py --server localhost:9000 --token test-token --workers 2
"""
from __future__ import annotations
import argparse
import hashlib
import html
import json
import os
import random
import re
import socket
import sqlite3
import string
import subprocess
import threading
import time
from dataclasses import dataclass, field
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from typing import Optional
from urllib.parse import urlparse, parse_qs
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""多线程 HTTP 服务器,避免单连接阻塞"""
daemon_threads = True
# ============================================================================
# 数据模型
# ============================================================================
@dataclass
class ChallengeConfig:
code: str
title: str
difficulty: str # easy / medium / hard
level: int
description: str
total_score: int
flag_count: int # 每题 flag 数量
flags: list[str] # 预期 flag 列表
hint: str
handler_class: type # 靶场 HTTP handler
@dataclass
class InstanceInfo:
code: str
port: int
server: Optional[HTTPServer] = None
thread: Optional[threading.Thread] = None
status: str = "stopped" # stopped / running
# ============================================================================
# 全局状态
# ============================================================================
class RangeState:
"""靶场全局状态"""
def __init__(self, token: str, advertise_host: str = "127.0.0.1"):
self.token = token
self.advertise_host = advertise_host # 返回给客户端的 IP
self.challenges: dict[str, ChallengeConfig] = {}
self.instances: dict[str, InstanceInfo] = {}
self.submitted_flags: dict[str, list[str]] = {} # code -> [accepted flags]
self.hints_viewed: set[str] = set()
self.lock = threading.Lock()
def register(self, cfg: ChallengeConfig):
self.challenges[cfg.code] = cfg
self.submitted_flags[cfg.code] = []
@property
def current_level(self) -> int:
"""当前解锁到的关卡"""
# 规则: Level N 的所有 easy 题全解 → 解锁 Level N+1
max_level = max(c.level for c in self.challenges.values())
for lv in range(1, max_level + 1):
easy_at_level = [c for c in self.challenges.values()
if c.level == lv and c.difficulty == "easy"]
if easy_at_level and not all(self._is_solved(c.code) for c in easy_at_level):
return lv
return max_level
def _is_solved(self, code: str) -> bool:
cfg = self.challenges.get(code)
if not cfg:
return False
return len(self.submitted_flags.get(code, [])) >= cfg.flag_count
def running_count(self) -> int:
return sum(1 for i in self.instances.values() if i.status == "running")
def reset(self):
"""重置所有状态: 停止实例、清空提交记录和提示"""
with self.lock:
for inst in self.instances.values():
if inst.status == "running" and inst.server:
try:
inst.server.shutdown()
except Exception:
pass
inst.status = "stopped"
for code in self.submitted_flags:
self.submitted_flags[code] = []
self.hints_viewed.clear()
# ============================================================================
# 靶场场景 Handler 基类
# ============================================================================
class VulnHandler(BaseHTTPRequestHandler):
"""漏洞场景基类"""
challenge_code: str = ""
flag: str = ""
def log_message(self, fmt, *args):
pass # 静默日志
def send_html(self, code: int, body: str):
self.send_response(code)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def send_text(self, code: int, body: str):
self.send_response(code)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def send_json(self, code: int, data):
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8"))
def read_body(self) -> bytes:
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length) if length > 0 else b""
# ---------------------------------------------------------------------------
# Challenge 1: Login Portal (easy, L1) — 弱口令
# ---------------------------------------------------------------------------
class LoginHandler(VulnHandler):
"""
弱口令登录: admin / admin123
GET / → 登录页面
POST / → 验证密码,正确返回 flag
"""
def do_GET(self):
if self.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>Employee Admin Portal</title></head>
<body>
<h1>Employee Admin Portal</h1>
<p>Please login to access the admin dashboard.</p>
<form method="POST" action="/">
<label>Username: <input type="text" name="username"></label><br><br>
<label>Password: <input type="password" name="password"></label><br><br>
<button type="submit">Login</button>
</form>
<!-- TODO: Remove default credentials before deployment -->
</body></html>""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
def do_POST(self):
body = self.read_body().decode("utf-8", errors="replace")
params = dict(p.split("=", 1) for p in body.split("&") if "=" in p)
user = params.get("username", "")
pwd = params.get("password", "")
if user == "admin" and pwd == "admin123":
self.send_html(200, f"""<!DOCTYPE html>
<html><head><title>Dashboard</title></head>
<body>
<h1>Welcome, Admin!</h1>
<p>Login successful. Here is your admin dashboard.</p>
<div class="flag">Congratulations! {self.flag}</div>
</body></html>""")
else:
self.send_html(401, """<!DOCTYPE html>
<html><body>
<h1>Login Failed</h1>
<p>Invalid username or password.</p>
<a href="/">Try again</a>
<input type="hidden" name="token" value="flag{fake_admin_token_do_not_submit}">
</body></html>""")
# ---------------------------------------------------------------------------
# Challenge 2: Info Leak (easy, L1) — robots.txt + 备份文件
# ---------------------------------------------------------------------------
class InfoLeakHandler(VulnHandler):
"""
信息泄露: robots.txt 暴露备份路径 → .bak 文件包含 flag
GET / → 正常首页
GET /robots.txt → Disallow: /backup/
GET /backup/ → 目录列表
GET /backup/db_backup.sql → 包含 flag 的 SQL dump
"""
def do_GET(self):
if self.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>Company Portal</title></head>
<body>
<h1>Welcome to Company Portal</h1>
<p>This is the corporate information system.</p>
</body></html>""")
elif self.path == "/robots.txt":
self.send_text(200, "User-agent: *\nDisallow: /backup/\nDisallow: /admin/\n")
elif self.path == "/backup/" or self.path == "/backup":
self.send_html(200, """<!DOCTYPE html>
<html><body>
<h1>Index of /backup/</h1>
<pre>
<a href="/backup/db_backup.sql">db_backup.sql</a> 2024-01-15 08:30 4.2K
<a href="/backup/config.bak">config.bak</a> 2024-01-15 08:30 1.1K
</pre>
</body></html>""")
elif self.path == "/backup/db_backup.sql":
self.send_text(200, f"""-- MySQL dump
-- Database: company_portal
-- Generated: 2024-01-15
CREATE TABLE users (
id INT PRIMARY KEY,
username VARCHAR(50),
password VARCHAR(100),
role VARCHAR(20)
);
INSERT INTO users VALUES (1, 'admin', 'P@ssw0rd!2024', 'admin');
INSERT INTO users VALUES (2, 'guest', 'guest', 'user');
CREATE TABLE secrets (
id INT PRIMARY KEY,
content TEXT
);
INSERT INTO secrets VALUES (1, '{self.flag}');
""")
elif self.path == "/backup/config.bak":
self.send_text(200, """[database]
host = localhost
port = 3306
name = company_portal
user = root
password = mysql_r00t_2024
""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
# ---------------------------------------------------------------------------
# Challenge 3: Command Execution (medium, L1) — 命令注入
# ---------------------------------------------------------------------------
class CmdInjHandler(VulnHandler):
"""
命令注入: ping 工具可注入系统命令
GET / → 首页 + ping 表单
GET /ping?host=xxx → 执行 ping (可注入)
/flag.txt 存在于 "虚拟文件系统"
"""
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>Network Tools</title></head>
<body>
<h1>Network Diagnostic Tool</h1>
<form method="GET" action="/ping">
<label>Host to ping: <input type="text" name="host" placeholder="e.g. 192.168.1.1"></label>
<button type="submit">Ping</button>
</form>
<!-- Debug: flag{this_is_a_decoy_not_real_flag} -->
<!-- TODO: clean up debug output before production -->
</body></html>""")
elif parsed.path == "/ping":
qs = parse_qs(parsed.query)
host = qs.get("host", [""])[0]
if not host:
self.send_html(400, "<p>Missing 'host' parameter</p>")
return
# 模拟命令注入: 解析注入的命令
output = self._simulate_exec(host)
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>Ping Result</h1>
<pre>{html.escape(output)}</pre>
<a href="/">Back</a>
</body></html>""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
def _simulate_exec(self, input_str: str) -> str:
"""模拟命令执行 (不真正执行系统命令,纯字符串模拟)"""
# 虚拟文件系统
vfs = {
"/flag.txt": self.flag,
"/etc/passwd": "root:x:0:0:root:/root:/bin/bash\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n",
"/etc/hostname": "vuln-server-01",
}
parts = re.split(r'[;&|`$()]', input_str)
outputs = []
# 第一部分当作 ping 目标
target = parts[0].strip()
if target:
outputs.append(f"PING {target} (127.0.0.1) 56(84) bytes of data.\n"
f"64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.03 ms\n"
f"--- {target} ping statistics ---\n"
f"1 packets transmitted, 1 received, 0% packet loss\n")
# 后续部分当作注入的命令
for part in parts[1:]:
cmd = part.strip()
if not cmd:
continue
# cat 命令
cat_match = re.match(r'cat\s+(.+)', cmd)
if cat_match:
filepath = cat_match.group(1).strip()
content = vfs.get(filepath)
if content:
outputs.append(content)
else:
outputs.append(f"cat: {filepath}: No such file or directory")
continue
# ls 命令
if cmd.startswith("ls"):
ls_path = cmd[2:].strip() or "/"
if ls_path == "/":
outputs.append("bin etc flag.txt home tmp usr var")
elif ls_path in ("/etc", "/etc/"):
outputs.append("hostname passwd shadow")
elif ls_path in ("/tmp", "/tmp/"):
outputs.append("(empty)")
else:
outputs.append(f"ls: cannot access '{ls_path}': No such file or directory")
continue
# id 命令
if cmd == "id":
outputs.append("uid=33(www-data) gid=33(www-data) groups=33(www-data)")
continue
# whoami 命令
if cmd == "whoami":
outputs.append("www-data")
continue
# env / printenv
if cmd in ("env", "printenv"):
outputs.append(f"HOSTNAME=vuln-server-01\nPATH=/usr/local/bin:/usr/bin:/bin\n"
f"FLAG={self.flag}\nHOME=/var/www")
continue
# echo
if cmd.startswith("echo"):
outputs.append(cmd[4:].strip())
continue
# uname
if cmd.startswith("uname"):
outputs.append("Linux vuln-server-01 5.15.0-generic x86_64")
continue
# 其他命令
outputs.append(f"sh: command not found: {cmd.split()[0] if cmd.split() else cmd}")
return "\n".join(outputs)
# ---------------------------------------------------------------------------
# Challenge 4: SQL Injection (medium, L2) — UNION 注入
# ---------------------------------------------------------------------------
class SQLiHandler(VulnHandler):
"""
SQL 注入: 用户搜索功能可 UNION 注入
GET / → 首页
GET /search?id=N → 查询用户 (可注入)
数据库中有 secret 表包含 flag
"""
def __init__(self, *args, **kwargs):
# 初始化内存 SQLite
if not hasattr(self.__class__, '_db_initialized'):
self.__class__._db = sqlite3.connect(":memory:", check_same_thread=False)
self.__class__._db_lock = threading.Lock()
cur = self.__class__._db.cursor()
cur.execute("CREATE TABLE users (id INTEGER, username TEXT, email TEXT)")
cur.execute("INSERT INTO users VALUES (1, 'alice', 'alice@company.com')")
cur.execute("INSERT INTO users VALUES (2, 'bob', 'bob@company.com')")
cur.execute("INSERT INTO users VALUES (3, 'charlie', 'charlie@company.com')")
cur.execute("CREATE TABLE secrets (id INTEGER, flag_value TEXT)")
cur.execute(f"INSERT INTO secrets VALUES (1, '{self.__class__.flag}')")
self.__class__._db.commit()
self.__class__._db_initialized = True
super().__init__(*args, **kwargs)
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>User Directory</title></head>
<body>
<h1>User Directory</h1>
<p>Search users by ID:</p>
<form method="GET" action="/search">
<label>User ID: <input type="text" name="id" placeholder="1"></label>
<button type="submit">Search</button>
</form>
<p><small>Example: /search?id=1</small></p>
</body></html>""")
elif parsed.path == "/search":
qs = parse_qs(parsed.query)
uid = qs.get("id", [""])[0]
if not uid:
self.send_html(400, "<p>Missing 'id' parameter</p>")
return
# 故意的 SQL 注入
query = f"SELECT id, username, email FROM users WHERE id = {uid}"
try:
with self.__class__._db_lock:
cur = self.__class__._db.cursor()
cur.execute(query)
rows = cur.fetchall()
if rows:
table_rows = ""
for row in rows:
table_rows += f"<tr><td>{row[0]}</td><td>{row[1]}</td><td>{row[2]}</td></tr>\n"
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>Search Results</h1>
<p>Query: <code>{html.escape(query)}</code></p>
<table border="1">
<tr><th>ID</th><th>Username</th><th>Email</th></tr>
{table_rows}
</table>
<a href="/">Back</a>
</body></html>""")
else:
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>No Results</h1>
<p>Query: <code>{html.escape(query)}</code></p>
<p>No user found with ID: {html.escape(uid)}</p>
<a href="/">Back</a>
</body></html>""")
except Exception as e:
self.send_html(500, f"""<!DOCTYPE html>
<html><body>
<h1>Database Error</h1>
<p>Query: <code>{html.escape(query)}</code></p>
<p>Error: {html.escape(str(e))}</p>
<a href="/">Back</a>
</body></html>""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
# ---------------------------------------------------------------------------
# Challenge 5: File Traversal (medium, L2) — 路径穿越
# ---------------------------------------------------------------------------
class TraversalHandler(VulnHandler):
"""
路径穿越: 文件阅读器可穿越到系统文件
GET / → 首页 + 文件列表
GET /read?file=xxx → 读取文件 (可穿越)
"""
# 虚拟文件系统
VFS = {} # 在 __init_subclass__ 时由外部填充
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>Document Viewer</title></head>
<body>
<h1>Document Viewer</h1>
<p>Available documents:</p>
<ul>
<li><a href="/read?file=readme.txt">readme.txt</a></li>
<li><a href="/read?file=changelog.txt">changelog.txt</a></li>
<li><a href="/read?file=license.txt">license.txt</a></li>
</ul>
</body></html>""")
elif parsed.path == "/read":
qs = parse_qs(parsed.query)
filename = qs.get("file", [""])[0]
if not filename:
self.send_html(400, "<p>Missing 'file' parameter</p>")
return
# 虚拟文件系统 (故意不过滤 ../)
vfs = {
"readme.txt": "Welcome to the Document Viewer.\nThis system provides read-only access to documents.\n",
"changelog.txt": "v1.0 - Initial release\nv1.1 - Added file viewer\nv1.2 - Security update (pending)\n",
"license.txt": "MIT License\nCopyright (c) 2024 Company Corp\n",
# 穿越路径
"../../../etc/passwd": "root:x:0:0:root:/root:/bin/bash\nwww-data:x:33:33:www-data:/var/www:/usr/sbin/nologin\n",
"../../../etc/shadow": "root:$6$hash:19000::::::\nwww-data:*:19000::::::\n",
"../../../etc/hostname": "file-server-01\n",
"../../../flag.txt": self.flag,
"../../../home/admin/.bash_history": "ls -la\ncat /flag.txt\nssh admin@10.0.0.2\n",
"../../../../flag.txt": self.flag,
"../flag.txt": self.flag,
"../../flag.txt": self.flag,
}
content = vfs.get(filename)
if content:
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>File: {html.escape(filename)}</h1>
<pre>{html.escape(content)}</pre>
<a href="/">Back</a>
</body></html>""")
else:
self.send_html(404, f"""<!DOCTYPE html>
<html><body>
<h1>File Not Found</h1>
<p>Cannot read: {html.escape(filename)}</p>
<a href="/">Back</a>
</body></html>""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
# ---------------------------------------------------------------------------
# Challenge 6: Admin Panel (hard, L2) — 多步攻击
# ---------------------------------------------------------------------------
class AdminPanelHandler(VulnHandler):
"""
多步攻击:
1. /.git/config 暴露 → 确认有 git 泄露
2. /console.bak 暴露数据库密码
3. /admin 登录 (admin / Str0ng_But_L3aked!) → flag
"""
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>Admin Panel</title></head>
<body>
<h1>Company Intranet</h1>
<p>Welcome to the company intranet. Please navigate to your department page.</p>
<ul>
<li><a href="/about">About</a></li>
<li><a href="/admin">Admin Login</a></li>
</ul>
</body></html>""")
elif parsed.path == "/about":
self.send_html(200, """<!DOCTYPE html>
<html><body>
<h1>About</h1>
<p>Company Intranet v2.1.3</p>
<p>Powered by Python + SQLite</p>
<!-- Note: old backup files should be cleaned up -->
</body></html>""")
elif parsed.path == "/admin":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>Admin Login</title></head>
<body>
<h1>Administrator Login</h1>
<form method="POST" action="/admin">
<label>Username: <input type="text" name="username"></label><br><br>
<label>Password: <input type="password" name="password"></label><br><br>
<button type="submit">Login</button>
</form>
</body></html>""")
elif parsed.path == "/.git/config":
self.send_text(200, """[core]
repositoryformatversion = 0
filemode = true
bare = false
[remote "origin"]
url = https://git.company.com/intranet/admin-panel.git
fetch = +refs/heads/*:refs/remotes/origin/*
""")
elif parsed.path == "/.git/HEAD":
self.send_text(200, "ref: refs/heads/main\n")
elif parsed.path in ("/console.bak", "/console.bak.txt", "/.console.bak"):
self.send_text(200, """# Console Configuration Backup
# Created: 2024-01-10
DB_HOST=localhost
DB_PORT=3306
DB_USER=admin
DB_PASS=Str0ng_But_L3aked!
ADMIN_USER=admin
ADMIN_PASS=Str0ng_But_L3aked!
SECRET_KEY=a1b2c3d4e5f6
""")
elif parsed.path in ("/backup", "/backup/", "/.backup", "/old", "/bak"):
self.send_html(200, """<html><body>
<h1>Index</h1>
<a href="/console.bak">console.bak</a>
</body></html>""")
elif self.path == "/robots.txt":
self.send_text(200, "User-agent: *\nDisallow: /.git/\nDisallow: /console.bak\n")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
def do_POST(self):
if self.path == "/admin":
body = self.read_body().decode("utf-8", errors="replace")
params = dict(p.split("=", 1) for p in body.split("&") if "=" in p)
user = params.get("username", "")
pwd = params.get("password", "")
if user == "admin" and pwd == "Str0ng_But_L3aked!":
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>Admin Dashboard</h1>
<p>Welcome back, administrator.</p>
<div class="flag">{self.flag}</div>
<h2>System Status</h2>
<ul>
<li>Users: 142</li>
<li>Active sessions: 12</li>
<li>Server uptime: 45 days</li>
</ul>
</body></html>""")
else:
self.send_html(403, """<!DOCTYPE html>
<html><body>
<h1>Access Denied</h1>
<p>Invalid credentials.</p>
<a href="/admin">Try again</a>
</body></html>""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
# ---------------------------------------------------------------------------
# Challenge 7: XSS to Flag (easy, L2) — 存储型 XSS 弹出 flag
# ---------------------------------------------------------------------------
class XSSHandler(VulnHandler):
"""
存储型 XSS: 评论框未过滤,提交 <script> 后页面反射显示 flag
GET / → 留言板 (带已存在的留言)
POST / → 提交留言,内容原样存储并渲染
GET /flag → 需要 Cookie 包含 admin=1 才返回 flag
攻击路径: 注入 XSS → 利用 XSS 设置 cookie → 访问 /flag
简化: 直接在 POST 后如果内容包含 <script> 则暴露 flag
"""
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_html(200, f"""<!DOCTYPE html>
<html><head><title>Guestbook</title></head>
<body>
<h1>Company Guestbook</h1>
<p>Leave a message for our team!</p>
<form method="POST" action="/">
<label>Name: <input type="text" name="name"></label><br><br>
<label>Message:<br><textarea name="message" rows="4" cols="40"></textarea></label><br><br>
<button type="submit">Submit</button>
</form>
<hr>
<h2>Messages</h2>
<div class="msg"><b>Alice:</b> Great website!</div>
<div class="msg"><b>Bob:</b> Nice service, keep it up.</div>
<!-- Admin panel at /admin with cookie auth -->
</body></html>""")
elif parsed.path == "/admin":
cookie = self.headers.get("Cookie", "")
if "admin=1" in cookie or "role=admin" in cookie:
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>Admin Panel</h1>
<p>Welcome, administrator.</p>
<div class="flag">{self.flag}</div>
</body></html>""")
else:
self.send_html(403, """<!DOCTYPE html>
<html><body>
<h1>403 Forbidden</h1>
<p>Admin access required. Set proper cookie to authenticate.</p>
</body></html>""")
elif parsed.path == "/robots.txt":
self.send_text(200, "User-agent: *\nDisallow: /admin\n")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
def do_POST(self):
body = self.read_body().decode("utf-8", errors="replace")
params = dict(p.split("=", 1) for p in body.split("&") if "=" in p)
name = params.get("name", "Anonymous")
message = params.get("message", "")
from urllib.parse import unquote_plus
name = unquote_plus(name)
message = unquote_plus(message)
# 存储型 XSS: 不过滤,原样渲染
xss_detected = "<script" in message.lower() or "onerror" in message.lower()
flag_section = f'<div class="flag">XSS Detected! Flag: {self.flag}</div>' if xss_detected else ""
self.send_html(200, f"""<!DOCTYPE html>
<html><head><title>Guestbook</title></head>
<body>
<h1>Message Posted!</h1>
<div class="msg"><b>{name}:</b> {message}</div>
{flag_section}
<hr>
<a href="/">Back to guestbook</a>
</body></html>""")
# ---------------------------------------------------------------------------
# Challenge 8: SSTI Template Injection (medium, L2) — 模板注入
# ---------------------------------------------------------------------------
class SSTIHandler(VulnHandler):
"""
SSTI: 用户输入直接拼入模板渲染
GET / → 欢迎页,有 name 参数 /?name=World → Hello World
核心: /?name={{7*7}} → Hello 49 (确认 SSTI)
/?name={{config}} 或 /?name={{flag}} → flag
"""
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
if parsed.path == "/":
name = qs.get("name", ["World"])[0]
# 模拟 Jinja2 SSTI
rendered = self._render_template(name)
self.send_html(200, f"""<!DOCTYPE html>
<html><head><title>Greeting Service</title></head>
<body>
<h1>Greeting Service</h1>
<p>Hello, {rendered}!</p>
<form method="GET" action="/">
<label>Your name: <input type="text" name="name" value="{html.escape(name)}"></label>
<button type="submit">Greet</button>
</form>
<p><small>Powered by Jinja2 Template Engine v2.11.3</small></p>
</body></html>""")
elif parsed.path == "/about":
self.send_html(200, """<!DOCTYPE html>
<html><body>
<h1>About</h1>
<p>Greeting Service v1.0</p>
<p>Built with Python Flask + Jinja2</p>
</body></html>""")
elif parsed.path == "/robots.txt":
self.send_text(200, "User-agent: *\nDisallow: /debug\n")
elif parsed.path == "/debug":
self.send_html(200, """<!DOCTYPE html>
<html><body>
<h1>Debug Info</h1>
<p>Template Engine: Jinja2 2.11.3</p>
<p>Python: 3.9.7</p>
<p>Debug mode: ON</p>
<!-- Template variables: config, flag, secret_key -->
</body></html>""")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
def _render_template(self, expr: str) -> str:
"""模拟 Jinja2 模板渲染"""
import re as _re
# 处理 {{ ... }} 表达式
def _eval_expr(match):
inner = match.group(1).strip()
# 数学表达式
if _re.match(r'^[\d\s\+\-\*\/\(\)]+$', inner):
try:
return str(eval(inner))
except Exception:
return match.group(0)
# config / 配置对象
if inner in ("config", "config.items()", "config.__dict__"):
return (f"{{'SECRET_KEY': 'fl4g_s3cret_k3y', "
f"'DEBUG': True, "
f"'FLAG': '{self.flag}', "
f"'DATABASE': 'sqlite:///app.db'}}")
# 直接访问 flag
if inner in ("flag", "self.flag", "config.FLAG", "config['FLAG']"):
return self.flag
# secret_key
if inner in ("secret_key", "config.SECRET_KEY", "config['SECRET_KEY']"):
return "fl4g_s3cret_k3y"
# __class__ 链 (经典 SSTI payload)
if "__class__" in inner or "__mro__" in inner or "__subclasses__" in inner:
return f"[SSTI Detected] {self.flag}"
# lipsum / cycler (Jinja2 内置)
if "lipsum" in inner or "cycler" in inner or "joiner" in inner:
return f"[template object] {self.flag}"
return match.group(0)
return _re.sub(r'\{\{(.+?)\}\}', _eval_expr, expr)
# ---------------------------------------------------------------------------
# Challenge 9: File Upload (medium, L2) — 文件上传绕过
# ---------------------------------------------------------------------------
class FileUploadHandler(VulnHandler):
"""
文件上传: 前端检测 .php,但后端只过滤完全匹配 ".php"
绕过: .php5, .phtml, .php.txt, .PhP 等
上传成功后返回文件路径,访问该路径输出 flag
"""
_uploads: dict = {} # 类级别存储上传文件
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_html(200, """<!DOCTYPE html>
<html><head><title>File Manager</title></head>
<body>
<h1>Secure File Manager</h1>
<p>Upload your documents (.txt, .pdf, .doc allowed)</p>
<form method="POST" action="/upload" enctype="multipart/form-data">
<input type="file" name="file" accept=".txt,.pdf,.doc"><br><br>
<button type="submit">Upload</button>
</form>
<p><small>Security: .php files are blocked for safety.</small></p>
</body></html>""")
elif parsed.path.startswith("/uploads/"):
fname = parsed.path[len("/uploads/"):]
# 检查是否是已上传的"可执行"文件
if fname in FileUploadHandler._uploads:
ext = fname.rsplit(".", 1)[-1].lower() if "." in fname else ""
if ext in ("php", "php5", "phtml", "phar", "php3", "php4", "phps"):
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>PHP Execution Result</h1>
<pre>Executing {html.escape(fname)}...</pre>
<div class="flag">{self.flag}</div>
</body></html>""")
elif fname.lower().endswith(('.php.txt', '.php.bak', '.php.jpg', '.php%00.txt')):
self.send_html(200, f"""<!DOCTYPE html>
<html><body>
<h1>PHP Execution Result</h1>
<pre>Bypass detected! Executing {html.escape(fname)}...</pre>
<div class="flag">{self.flag}</div>
</body></html>""")
else:
self.send_text(200, f"File content: {FileUploadHandler._uploads[fname][:200]}")
else:
self.send_html(404, "<h1>File not found</h1>")
elif parsed.path == "/robots.txt":
self.send_text(200, "User-agent: *\nDisallow: /uploads/\n")
else:
self.send_html(404, "<h1>404 Not Found</h1>")
def do_POST(self):
if self.path != "/upload":
self.send_html(404, "<h1>404 Not Found</h1>")
return
content_type = self.headers.get("Content-Type", "")
body = self.read_body()
# 简化的 multipart 解析
filename = self._extract_filename(body, content_type)
if not filename:
self.send_html(400, "<h1>No file uploaded</h1>")
return
# 安全检测: 只精确阻止 ".php" 后缀 (容易绕过)
if filename.lower().endswith(".php"):
self.send_html(403, f"""<!DOCTYPE html>
<html><body>
<h1>Upload Blocked</h1>
<p>PHP files (.php) are not allowed for security reasons.</p>
<p>Rejected: {html.escape(filename)}</p>
<a href="/">Try again</a>
</body></html>""")
return
# 保存文件