#!/usr/bin/env python3 """Disposable Linux guest only. Never run on a workstation or collector.""" import base64 import hashlib import json import os import pathlib import signal import stat import subprocess import sys import threading import time LOCK = threading.Lock() LIMIT = 16 * 1024 * 1024 ACTIVE = None ROOTS = ['/var/tmp', '/tmp', '/root', '/netscaler', '/nsconfig', '/usr/local/bin', '/etc'] def emit(event, content=None, **data): if content is not None: data['base64'] = base64.b64encode(content).decode() with LOCK: try: print(json.dumps(dict(event=event, guest_time_ns=time.time_ns(), **data)), flush=True) except (BrokenPipeError, OSError): terminate() os._exit(1) def scan(): result = {} for root in ROOTS: for folder, dirs, files in os.walk(root, followlinks=False): dirs[:] = dirs[:128] for name in files: path = os.path.join(folder, name) try: s = os.lstat(path) if stat.S_ISREG(s.st_mode): result[path] = (s.st_ino, s.st_mtime_ns, s.st_ctime_ns, s.st_size, s.st_mode) except OSError: pass if len(result) >= 20000: return result return result def watch(stop, previous): # ponytail: polling preserves observed versions; syscall trace records fast deletes/other paths. while not stop.wait(.25): current = scan() for path in previous.keys() - current.keys(): emit('guest_file_deleted', path=path) for path, info in current.items(): if previous.get(path) == info: continue try: fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) with os.fdopen(fd, 'rb') as f: if not stat.S_ISREG(os.fstat(f.fileno()).st_mode): continue data = f.read(LIMIT + 1) emit('guest_file_version', content=data[:LIMIT], path=path, size=info[3], mode=oct(info[4]), truncated=len(data) > LIMIT) except OSError as e: emit('guest_file_capture_error', path=path, error=str(e)) previous = current def terminate(*_): if ACTIVE is not None: try: os.killpg(ACTIVE.pid, signal.SIGKILL) except ProcessLookupError: pass def open_beneath(path, flags, mode=0o600): # Resolve every directory with NOFOLLOW, including mutable /var/tmp/uploads. parts = pathlib.PurePosixPath(path).parts if parts[0] != '/' or '..' in parts: raise ValueError('invalid path') directory = os.open('/', os.O_RDONLY | os.O_DIRECTORY) try: for part in parts[1:-1]: new = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory) os.close(directory) directory = new return os.open(parts[-1], flags | os.O_NOFOLLOW | os.O_NONBLOCK, mode, dir_fd=directory) finally: os.close(directory) def main(): global ACTIVE marker = pathlib.Path('/etc/decoy-disposable-guest') if not marker.is_file() or marker.is_symlink(): raise SystemExit('Refusing execution outside disposable guest') maximum = 4 * ((LIMIT + 2) // 3) + 131072 line = sys.stdin.buffer.readline(maximum + 1) if len(line) > maximum or not line.endswith(b'\n'): raise SystemExit('request limit') data = json.loads(line) if data['action'] == 'read': path = pathlib.PurePosixPath(data['path']) if not str(path).startswith('/netscaler/ns_gui/') or '..' in path.parts: raise SystemExit('invalid webroot path') fd = open_beneath(str(path), os.O_RDONLY) with os.fdopen(fd, 'rb') as f: if not stat.S_ISREG(os.fstat(f.fileno()).st_mode): raise SystemExit('not regular file') raw = f.read(LIMIT + 1) emit('guest_http_file', content=raw[:LIMIT], path=str(path), truncated=len(raw) > LIMIT) return if data['action'] == 'upload': raw = base64.b64decode(data['base64'], validate=True) if len(raw) > LIMIT: raise SystemExit('upload limit') name = '/var/tmp/uploads/' + hashlib.sha256(raw).hexdigest() + '.bin' os.makedirs('/var/tmp/uploads', mode=0o700, exist_ok=True) # Never truncate attacker-selected symlinks, hardlinks, devices or existing files. try: fd = open_beneath(name, os.O_WRONLY | os.O_CREAT | os.O_EXCL) except FileExistsError: emit('guest_upload', path=name, bytes=len(raw), error='path exists; offguest original retained') return with os.fdopen(fd, 'wb') as f: f.write(raw) emit('guest_upload', path=name, bytes=len(raw)) return if data['action'] != 'execute': raise SystemExit('unknown action') command = data['command'] if not isinstance(command, str) or len(command) > 65536: raise SystemExit('command limit') for sig in (signal.SIGHUP, signal.SIGTERM, signal.SIGINT): signal.signal(sig, lambda *_: (terminate(), sys.exit(1))) stop = threading.Event() watcher = threading.Thread(target=watch, args=(stop, scan()), daemon=True) watcher.start() proc = subprocess.Popen(['strace', '-f', '-ttt', '-T', '-s', '4096', '-yy', '/bin/bash', '-c', command], cwd='/var/tmp', stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True) ACTIVE = proc emit('guest_process_start', pid=proc.pid, command=command) def drain(stream, event): while chunk := stream.read1(32768): emit(event, content=chunk) readers = [threading.Thread(target=drain, args=(proc.stdout, 'guest_stdout'), daemon=True), threading.Thread(target=drain, args=(proc.stderr, 'guest_syscalls_stderr'), daemon=True)] for reader in readers: reader.start() try: proc.wait(timeout=600) except subprocess.TimeoutExpired: emit('guest_execution_limit', seconds=600) finally: try: os.killpg(proc.pid, signal.SIGKILL) except ProcessLookupError: pass proc.wait() for reader in readers: reader.join(3) time.sleep(.5) stop.set() watcher.join(2) emit('guest_process_end', returncode=proc.poll()) if __name__ == '__main__': main()