enowars5-service-stldoctor

STL-Analyzing A/D Service for ENOWARS5 in 2021
git clone https://git.sinitax.com/sinitax/enowars5-service-stldoctor
Log | Files | Refs | README | LICENSE | sfeed.txt

commit 8aac44bb98af5442e29c8cb9a5a4acbe40d96bb2
parent 53156862fa68b130c9a57f2824275f99017929ac
Author: Louis Burda <quent.burda@gmail.com>
Date:   Wed, 28 Apr 2021 10:51:50 +0200

added sample service templates, basic service outline and moved service info to documentation dir

Diffstat:
MREADME.md | 71++---------------------------------------------------------------------
Achecker/Dockerfile | 17+++++++++++++++++
Achecker/data/.gitkeep | 0
Achecker/docker-compose.yml | 24++++++++++++++++++++++++
Achecker/src/checker.py | 387+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Achecker/src/gunicorn.conf.py | 8++++++++
Achecker/src/requirements.txt | 24++++++++++++++++++++++++
Adocumentation/README.md | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aservice/Dockerfile | 18++++++++++++++++++
Aservice/docker-compose.yml | 8++++++++
Aservice/entrypoint.sh | 10++++++++++
Aservice/src/.gitignore | 2++
Aservice/src/Makefile | 6++++++
Aservice/src/cat | 5+++++
Aservice/src/printdoc.c | 134+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aservice/src/stlfile.c | 4++++
Aservice/src/stlfile.h | 10++++++++++
17 files changed, 727 insertions(+), 69 deletions(-)

diff --git a/README.md b/README.md @@ -1,72 +1,5 @@ -enowars-5 printdoc -================== +Enowars5 PrintDoc +================= An stl file info service. - -setup ------ - -The service is hosted with ynetd or similar, one process per client. - -You submit an stl file and the service gives you details about the file: - -- how many triangles -- file type (bin/ascii) -- name -- attributes (binary header parsing) - -The file upload size has to be below a certain limit (4kB?). - -The files are simply stored in a directory and cleaned up -via a crontab which checks their *last modified* date. - -The model name is used to create hash / id which also -acts as directory name for the actual stl and parsed info. - -Error msg if too many verticies for one loop.. see vulnerability. - -Error msg if invalid format. - - -countermeasures ---------------- - -Countermeasures against malicious players, who via an -unintended vulnerability gain remote code execution: - - -checker -------- - -The flag is saved as a 3d model of the flag text. One needs -to orient it, take a screenshot and decode the text from the -image for automated exploitation. - - -vulnerability -------------- - -If there are > 3 verticies in a `loop` in the stl, a warning -message is returned by preparing and `printf`ing a buffer, -however, WITHOUT a terminating null byte. As such, when -processing the string, we read into the stack-adjacent integer -that holds the file's attribute byte count. This value -is zero by default so the buffer overflow will go unnoticed. - -We can set this value to 0x6e25 (= 28197), which corresponds -to the string '%n' on a little endian system. - -When the warning prints, it will write the size of the -format string (which can be controlled via the model name) -to the address of the next value on the stack: the hash str. -By varying this value to write 256 aka 0x100 we terminate -the string with a null byte, making it an empty. - -Next, the program will return the info of all scans that match -the hash prefix (files are saved in a directory <hash>-<timestamp>). -Since the hash is not empty the information for each scan will be -returned, including the id, which can be used to request the flag file. - - - diff --git a/checker/Dockerfile b/checker/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.9 + +# Create user +RUN useradd -ms /bin/bash -u 1000 checker + +USER checker +WORKDIR /checker + +# Install all required dependencies for the checker. +COPY ./src/requirements.txt /checker/requirements.txt +RUN pip3 install -r requirements.txt + +# Copy all files into the container. +COPY ./src/ /checker/ + + +ENTRYPOINT [ "/home/checker/.local/bin/gunicorn", "-c", "gunicorn.conf.py", "checker:app" ] diff --git a/checker/data/.gitkeep b/checker/data/.gitkeep diff --git a/checker/docker-compose.yml b/checker/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3' +services: + # Give your container proper names! + n0t3b00k-checker: + build: . + # The checker runs a HTTP interfaces, so we need to map port 3031 to the outside (port 8000). + ports: + - 8000:3031 + environment: + - MONGO_ENABLED=1 + - MONGO_HOST=n0t3b00k-mongo + - MONGO_PORT=27017 + - MONGO_USER=n0t3b00k_checker + - MONGO_PASSWORD=n0t3b00k_checker + + # The python checkerlib requires a mongo db! + n0t3b00k-mongo: + image: mongo + volumes: + - ./data:/data/db + environment: + MONGO_INITDB_ROOT_USERNAME: n0t3b00k_checker + MONGO_INITDB_ROOT_PASSWORD: n0t3b00k_checker +\ No newline at end of file diff --git a/checker/src/checker.py b/checker/src/checker.py @@ -0,0 +1,387 @@ +#!/usr/bin/env python3 +from enochecker import BaseChecker, BrokenServiceException, EnoException, run +from enochecker.utils import SimpleSocket, assert_equals, assert_in +import random +import string + +#### Checker Tenets +# A checker SHOULD not be easily identified by the examination of network traffic => This one is not satisfied, because our usernames and notes are simple too random and easily identifiable. +# A checker SHOULD use unusual, incorrect or pseudomalicious input to detect network filters => This tenet is not satisfied, because we do not send common attack strings (i.e. for SQL injection, RCE, etc.) in our notes or usernames. +#### + + +class N0t3b00kChecker(BaseChecker): + """ + Change the methods given here, then simply create the class and .run() it. + Magic. + A few convenient methods and helpers are provided in the BaseChecker. + ensure_bytes ans ensure_unicode to make sure strings are always equal. + As well as methods: + self.connect() connects to the remote server. + self.get and self.post request from http. + self.chain_db is a dict that stores its contents to a mongodb or filesystem. + conn.readline_expect(): fails if it's not read correctly + To read the whole docu and find more goodies, run python -m pydoc enochecker + (Or read the source, Luke) + """ + + ##### EDIT YOUR CHECKER PARAMETERS + flag_variants = 1 + noise_variants = 1 + havoc_variants = 3 + service_name = "n0t3b00k" + port = 2323 # The port will automatically be picked up as default by self.connect and self.http. + ##### END CHECKER PARAMETERS + + def register_user(self, conn: SimpleSocket, username: str, password: str): + self.debug( + f"Sending command to register user: {username} with password: {password}" + ) + conn.write(f"reg {username} {password}\n") + conn.readline_expect( + b"User successfully registered", + read_until=b">", + exception_message="Failed to register user", + ) + + def login_user(self, conn: SimpleSocket, username: str, password: str): + self.debug(f"Sending command to login.") + conn.write(f"log {username} {password}\n") + conn.readline_expect( + b"Successfully logged in!", + read_until=b">", + exception_message="Failed to log in", + ) + + def putflag(self): # type: () -> None + """ + This method stores a flag in the service. + In case multiple flags are provided, self.variant_id gives the appropriate index. + The flag itself can be retrieved from self.flag. + On error, raise an Eno Exception. + :raises EnoException on error + :return this function can return a result if it wants + if nothing is returned, the service status is considered okay. + the preferred way to report errors in the service is by raising an appropriate enoexception + """ + if self.variant_id == 0: + # First we need to register a user. So let's create some random strings. (Your real checker should use some funny usernames or so) + username: str = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + password: str = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + + # Log a message before any critical action that could raise an error. + self.debug(f"Connecting to service") + # Create a TCP connection to the service. + conn = self.connect() + welcome = conn.read_until(">") + + # Register a new user + self.register_user(conn, username, password) + + # Now we need to login + self.login_user(conn, username, password) + + # Finally, we can post our note! + self.debug(f"Sending command to set the flag") + conn.write(f"set {self.flag}\n") + conn.read_until(b"Note saved! ID is ") + + try: + # Try to retrieve the resulting noteId. Using rstrip() is hacky, you should probably want to use regular expressions or something more robust. + noteId = conn.read_until(b"!\n>").rstrip(b"!\n>").decode() + except Exception as ex: + self.debug(f"Failed to retrieve note: {ex}") + raise BrokenServiceException("Could not retrieve NoteId") + + assert_equals(len(noteId) > 0, True, message="Empty noteId received") + + self.debug(f"Got noteId {noteId}") + + # Exit! + self.debug(f"Sending exit command") + conn.write(f"exit\n") + conn.close() + + # Save the generated values for the associated getflag() call. + # This is not a real dictionary! You cannot update it (i.e., self.chain_db["foo"] = bar) and some types are converted (i.e., bool -> str.). See: https://github.com/enowars/enochecker/issues/27 + self.chain_db = { + "username": username, + "password": password, + "noteId": noteId, + } + + else: + raise EnoException("Wrong variant_id provided") + + def getflag(self): # type: () -> None + """ + This method retrieves a flag from the service. + Use self.flag to get the flag that needs to be recovered and self.round to get the round the flag was placed in. + On error, raise an EnoException. + :raises EnoException on error + :return this function can return a result if it wants + if nothing is returned, the service status is considered okay. + the preferred way to report errors in the service is by raising an appropriate enoexception + """ + if self.variant_id == 0: + # First we check if the previous putflag succeeded! + try: + username: str = self.chain_db["username"] + password: str = self.chain_db["password"] + noteId: str = self.chain_db["noteId"] + except IndexError as ex: + self.debug(f"error getting notes from db: {ex}") + raise BrokenServiceException("Previous putflag failed.") + + self.debug(f"Connecting to the service") + conn = self.connect() + welcome = conn.read_until(">") + + # Let's login to the service + self.login_user(conn, username, password) + + # Let´s obtain our note. + self.debug(f"Sending command to retrieve note: {noteId}") + conn.write(f"get {noteId}\n") + note = conn.read_until(">") + assert_in( + self.flag.encode(), note, "Resulting flag was found to be incorrect" + ) + + # Exit! + self.debug(f"Sending exit command") + conn.write(f"exit\n") + conn.close() + else: + raise EnoException("Wrong variant_id provided") + + + def putnoise(self): # type: () -> None + """ + This method stores noise in the service. The noise should later be recoverable. + The difference between noise and flag is, that noise does not have to remain secret for other teams. + This method can be called many times per round. Check how often using self.variant_id. + On error, raise an EnoException. + :raises EnoException on error + :return this function can return a result if it wants + if nothing is returned, the service status is considered okay. + the preferred way to report errors in the service is by raising an appropriate enoexception + """ + if self.variant_id == 0: + self.debug(f"Connecting to the service") + conn = self.connect() + welcome = conn.read_until(">") + + # First we need to register a user. So let's create some random strings. (Your real checker should use some better usernames or so [i.e., use the "faker¨ lib]) + username = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + password = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + randomNote = "".join( + random.choices(string.ascii_uppercase + string.digits, k=36) + ) + + # Register another user + self.register_user(conn, username, password) + + # Now we need to login + self.login_user(conn, username, password) + + # Finally, we can post our note! + self.debug(f"Sending command to save a note") + conn.write(f"set {randomNote}\n") + conn.read_until(b"Note saved! ID is ") + + try: + noteId = conn.read_until(b"!\n>").rstrip(b"!\n>").decode() + except Exception as ex: + self.debug(f"Failed to retrieve note: {ex}") + raise BrokenServiceException("Could not retrieve NoteId") + + assert_equals(len(noteId) > 0, True, message="Empty noteId received") + + self.debug(f"{noteId}") + + # Exit! + self.debug(f"Sending exit command") + conn.write(f"exit\n") + conn.close() + + self.chain_db = { + "username": username, + "password": password, + "noteId": noteId, + "note": randomNote, + } + else: + raise EnoException("Wrong variant_id provided") + + def getnoise(self): # type: () -> None + """ + This method retrieves noise in the service. + The noise to be retrieved is inside self.flag + The difference between noise and flag is, that noise does not have to remain secret for other teams. + This method can be called many times per round. Check how often using variant_id. + On error, raise an EnoException. + :raises EnoException on error + :return this function can return a result if it wants + if nothing is returned, the service status is considered okay. + the preferred way to report errors in the service is by raising an appropriate enoexception + """ + if self.variant_id == 0: + try: + username: str = self.chain_db["username"] + password: str = self.chain_db["password"] + noteId: str = self.chain_db["noteId"] + randomNote: str = self.chain_db["note"] + except Exception as ex: + self.debug("Failed to read db {ex}") + raise BrokenServiceException("Previous putnoise failed.") + + self.debug(f"Connecting to service") + conn = self.connect() + welcome = conn.read_until(">") + + # Let's login to the service + self.login_user(conn, username, password) + + # Let´s obtain our note. + self.debug(f"Sending command to retrieve note: {noteId}") + conn.write(f"get {noteId}\n") + conn.readline_expect( + randomNote.encode(), + read_until=b">", + exception_message="Resulting flag was found to be incorrect" + ) + + # Exit! + self.debug(f"Sending exit command") + conn.write(f"exit\n") + conn.close() + else: + raise EnoException("Wrong variant_id provided") + + def havoc(self): # type: () -> None + """ + This method unleashes havoc on the app -> Do whatever you must to prove the service still works. Or not. + On error, raise an EnoException. + :raises EnoException on Error + :return This function can return a result if it wants + If nothing is returned, the service status is considered okay. + The preferred way to report Errors in the service is by raising an appropriate EnoException + """ + self.debug(f"Connecting to service") + conn = self.connect() + welcome = conn.read_until(">") + + if self.variant_id == 0: + # In variant 1, we'll check if the help text is available + self.debug(f"Sending help command") + conn.write(f"help\n") + is_ok = conn.read_until(">") + + for line in [ + "This is a notebook service. Commands:", + "reg USER PW - Register new account", + "log USER PW - Login to account", + "set TEXT..... - Set a note", + "user - List all users", + "list - List all notes", + "exit - Exit!", + "dump - Dump the database", + "get ID", + ]: + assert_in(line.encode(), is_ok, "Received incomplete response.") + + elif self.variant_id == 1: + # In variant 2, we'll check if the `user` command still works. + username = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + password = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + + # Register and login a dummy user + self.register_user(conn, username, password) + self.login_user(conn, username, password) + + self.debug(f"Sending user command") + conn.write(f"user\n") + ret = conn.readline_expect( + "User 0: ", + read_until=b">", + exception_message="User command does not return any users", + ) + + if username: + assert_in(username.encode(), ret, "Flag username not in user output") + + elif self.variant_id == 2: + # In variant 2, we'll check if the `list` command still works. + username = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + password = "".join( + random.choices(string.ascii_uppercase + string.digits, k=12) + ) + randomNote = "".join( + random.choices(string.ascii_uppercase + string.digits, k=36) + ) + + # Register and login a dummy user + self.register_user(conn, username, password) + self.login_user(conn, username, password) + + self.debug(f"Sending command to save a note") + conn.write(f"set {randomNote}\n") + conn.read_until(b"Note saved! ID is ") + + try: + noteId = conn.read_until(b"!\n>").rstrip(b"!\n>").decode() + except Exception as ex: + self.debug(f"Failed to retrieve note: {ex}") + raise BrokenServiceException("Could not retrieve NoteId") + + assert_equals(len(noteId) > 0, True, message="Empty noteId received") + + self.debug(f"{noteId}") + + self.debug(f"Sending list command") + conn.write(f"list\n") + conn.readline_expect( + noteId.encode(), + read_until=b'>', + exception_message="List command does not work as intended" + ) + + else: + raise EnoException("Wrong variant_id provided") + + # Exit! + self.debug(f"Sending exit command") + conn.write(f"exit\n") + conn.close() + + def exploit(self): + """ + This method was added for CI purposes for exploits to be tested. + Will (hopefully) not be called during actual CTF. + :raises EnoException on Error + :return This function can return a result if it wants + If nothing is returned, the service status is considered okay. + The preferred way to report Errors in the service is by raising an appropriate EnoException + """ + # TODO: We still haven't decided if we want to use this function or not. TBA + pass + + +app = N0t3b00kChecker.service # This can be used for uswgi. +if __name__ == "__main__": + run(N0t3b00kChecker) diff --git a/checker/src/gunicorn.conf.py b/checker/src/gunicorn.conf.py @@ -0,0 +1,7 @@ +# This is a configuration file required by the checker. +import multiprocessing +worker_class = "eventlet" +workers = multiprocessing.cpu_count()*2+1 +bind = "0.0.0.0:3031" +timeout = 90 +keepalive = 3600 +\ No newline at end of file diff --git a/checker/src/requirements.txt b/checker/src/requirements.txt @@ -0,0 +1,23 @@ +certifi==2020.12.5 +chardet==4.0.0 +click==7.1.2 +dnspython==1.16.0 +enochecker==0.3.2 +enochecker-cli==0.6.0 +enochecker-core==0.8.1 +eventlet==0.30.2 +Flask==1.1.2 +greenlet==1.0.0 +gunicorn==20.1.0 +idna==2.10 +itsdangerous==1.1.0 +Jinja2==2.11.3 +jsons==1.4.2 +MarkupSafe==1.1.1 +pycryptodome==3.10.1 +pymongo==3.11.3 +requests==2.25.1 +six==1.15.0 +typish==1.9.2 +urllib3==1.26.4 +Werkzeug==1.0.1 +\ No newline at end of file diff --git a/documentation/README.md b/documentation/README.md @@ -0,0 +1,68 @@ +PrintDoc +======== + +Setup +----- + +The service is hosted with ynetd or similar, one process per client. + +You submit an stl file and the service gives you details about the file: + +- how many triangles +- file type (bin/ascii) +- name +- attributes (binary header parsing) + +The file upload size has to be below a certain limit (4kB?). + +The files are simply stored in a directory and cleaned up +via a crontab which checks their *last modified* date. + +The model name is used to create hash / id which also +acts as directory name for the actual stl and parsed info. + +Error msg if too many verticies for one loop.. see vulnerability. + +Error msg if invalid format. + + +Countermeasures +--------------- + +Countermeasures against malicious players, who via an +unintended vulnerability gain remote code execution: + + +Checker +------- + +The flag is saved as a 3d model of the flag text. One needs +to orient it, take a screenshot and decode the text from the +image for automated exploitation. + + +Vulnerability +------------- + +If there are > 3 verticies in a `loop` in the stl, a warning +message is returned by preparing and `printf`ing a buffer, +however, WITHOUT a terminating null byte. As such, when +processing the string, we read into the stack-adjacent integer +that holds the file's attribute byte count. This value +is zero by default so the buffer overflow will go unnoticed. + +We can set this value to 0x6e25 (= 28197), which corresponds +to the string '%n' on a little endian system. + +When the warning prints, it will write the size of the +format string (which can be controlled via the model name) +to the address of the next value on the stack: the hash str. +By varying this value to write 256 aka 0x100 we terminate +the string with a null byte, making it an empty. + +Next, the program will return the info of all scans that match +the hash prefix (files are saved in a directory <hash>-<timestamp>). +Since the hash is not empty the information for each scan will be +returned, including the id, which can be used to request the flag file. + + diff --git a/service/Dockerfile b/service/Dockerfile @@ -0,0 +1,18 @@ +FROM ubuntu:18.04 + +RUN apt update && apt install -y --no-install-recommends socat + +RUN addgroup --system service +RUN adduser --system --ingroup service --uid 1000 service + +RUN mkdir /data + +COPY entrypoint.sh / +RUN chmod +x /entrypoint.sh + +COPY src/ /service/ + +WORKDIR /service/ +EXPOSE 9000 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/service/docker-compose.yml b/service/docker-compose.yml @@ -0,0 +1,8 @@ +version: '1' +services: + printdoc: + build: . + volumes: + - ./data/:/data:rw + ports: + - "2323:8000" diff --git a/service/entrypoint.sh b/service/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e +set -x + +# Chown the mounted data volume +chown -R service:service "/data/" + +# Launch our service as user 'service' +exec su -s /bin/sh -c 'PYTHONUNBUFFERED=1 python3 n0t3b00k.py' service +\ No newline at end of file diff --git a/service/src/.gitignore b/service/src/.gitignore @@ -0,0 +1,2 @@ +printdoc +*.o diff --git a/service/src/Makefile b/service/src/Makefile @@ -0,0 +1,6 @@ + +%.o: %.c %.h + $(CC) -c -o $@ $< -I . + +printdoc: printdoc.c stlfile.o + $(CC) -o $@ $< $(CFLAGS) $(LDLIBS) diff --git a/service/src/cat b/service/src/cat @@ -0,0 +1,5 @@ + + /\_/\ [ENO] _ + = u u =_______| \\ < GET THE FLAG! ;) + _ w __( \__)) + c_____>__(_____)__, diff --git a/service/src/printdoc.c b/service/src/printdoc.c @@ -0,0 +1,134 @@ +#include <stdio.h> +#include <string.h> +#include <stdarg.h> +#include <unistd.h> + +#include "stlfile.h" + +#define ARRSIZE(x) (sizeof(x)/sizeof((x)[0])) + +struct command { + const char *name; + void (*func)(); +}; + +void request_info(); +void parse_file(); +void cat_flag(); +void list_commands(); + +struct command commands[] = { + { "request", request_info }, + { "info", parse_file }, + { "cat", cat_flag }, + { "ls", list_commands }, +}; + +const char* +ask(const char *fmtstr, ...) +{ + static char linebuf[256]; + va_list ap; + + va_start(ap, fmtstr); + vprintf(fmtstr, ap); + va_end(ap); + + fgets(linebuf, sizeof(linebuf), stdin); + + return linebuf; +} + +void* +die(const char *fmtstr, ...) +{ + va_list ap; + + va_start(ap, fmtstr); + vfprintf(stderr, fmtstr, ap); + va_end(ap); + + exit(EXIT_FAILURE); +} + +void +request_info() +{ + const char *bufp; + char *end, *contents; + int len; + + bufp = ask("How large is your file?\n"); + len = strtoul(bufp, &end, 10); + if (!len || *end || len < 7000) + die("Invalid file length!\n"); + + printf("Ok! Im listening..\n"); + contents = malloc(len); + read(STDIN_FILENO, contents, len); + + printf("I got: %s\n", contents); + + free(contents); +} + +void +parse_file() +{ + printf("hi\n"); +} + +void +cat_flag() +{ + FILE *f; + char catbuf[256]; + int nb; + + f = fopen("cat", "r"); + nb = fread(catbuf, 1, sizeof(catbuf), f); + fclose(f); + + printf("%.*s\n", nb, catbuf); +} + +void +list_commands() +{ + int i; + + printf("Available commands:\n"); + for (i = 0; i < ARRSIZE(commands); i++) + printf("%s%s", i ? " " : "", commands[i].name); + printf("\n"); +} + +int +main() +{ + char linebuf[256], *cp; + int exit, i; + + exit = 0; + while (!exit) { + memset(linebuf, ' ', sizeof(linebuf)); + + printf("$ "); + exit = !fgets(linebuf, sizeof(linebuf), stdin); + if (exit || !*linebuf) break; + + if (*linebuf == '\n') continue; + linebuf[strlen(linebuf) - 1] = '\0'; + + cp = strchr(linebuf, ' '); + if (cp) *cp = 0; + for (i = 0; i < ARRSIZE(commands); i++) { + if (!strcmp(commands[i].name, linebuf)) { + commands[i].func(); + break; + } + } + } + + printf("see you later!\n"); +} diff --git a/service/src/stlfile.c b/service/src/stlfile.c @@ -0,0 +1,4 @@ +#include "stlfile.h" + + + diff --git a/service/src/stlfile.h b/service/src/stlfile.h @@ -0,0 +1,10 @@ +#ifndef STLFILE_H +#define STLFILE_H + +#include <stdlib.h> +#include <string.h> + + + + +#endif // STLFILE_H