176 lines
6.5 KiB
Python
176 lines
6.5 KiB
Python
#!/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()
|