289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""Bounded, recording TCP egress broker and Linux transparent guest relay.
|
|||
|
|
|
||
|
|
Guest metadata is untrusted. The outside broker makes all destination decisions.
|
||
|
|
No TLS interception: encrypted stream bytes remain encrypted evidence.
|
||
|
|
"""
|
||
|
|
import argparse
|
||
|
|
import ipaddress
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import pathlib
|
||
|
|
import select
|
||
|
|
import socket
|
||
|
|
import socketserver
|
||
|
|
import stat
|
||
|
|
import struct
|
||
|
|
import threading
|
||
|
|
import time
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
from evidence import Evidence
|
||
|
|
|
||
|
|
MAX_STREAM = 16 * 1024 * 1024
|
||
|
|
TTL = 600
|
||
|
|
STOP = pathlib.Path('/var/lib/decoy/STOP')
|
||
|
|
|
||
|
|
|
||
|
|
def public_ip(value):
|
||
|
|
ip = ipaddress.IPv4Address(value)
|
||
|
|
if not ip.is_global or ip.is_multicast or ip.is_reserved:
|
||
|
|
raise ValueError('destination is not public unicast IPv4')
|
||
|
|
return str(ip)
|
||
|
|
|
||
|
|
|
||
|
|
def read_message(sock):
|
||
|
|
data = bytearray()
|
||
|
|
while len(data) < 4096:
|
||
|
|
char = sock.recv(1)
|
||
|
|
if char == b'\n':
|
||
|
|
message = json.loads(data)
|
||
|
|
if not isinstance(message, dict):
|
||
|
|
raise ValueError('expected object')
|
||
|
|
return message
|
||
|
|
if not char:
|
||
|
|
raise ValueError('incomplete request')
|
||
|
|
data.extend(char)
|
||
|
|
raise ValueError('request too large')
|
||
|
|
|
||
|
|
|
||
|
|
def send_message(sock, value):
|
||
|
|
sock.sendall(json.dumps(value, separators=(',', ':')).encode() + b'\n')
|
||
|
|
|
||
|
|
|
||
|
|
def allowed_peers(path):
|
||
|
|
try:
|
||
|
|
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
||
|
|
with os.fdopen(fd, 'rb') as stream:
|
||
|
|
info = os.fstat(stream.fileno())
|
||
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022 or time.time() - info.st_mtime > 3600:
|
||
|
|
return set()
|
||
|
|
data = stream.read(65537)
|
||
|
|
if len(data) > 65536:
|
||
|
|
return set()
|
||
|
|
peers = json.loads(data)
|
||
|
|
if not isinstance(peers, list):
|
||
|
|
return set()
|
||
|
|
result = set()
|
||
|
|
for peer in peers:
|
||
|
|
try:
|
||
|
|
if isinstance(peer, str):
|
||
|
|
result.add(public_ip(peer))
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
return result
|
||
|
|
except (OSError, ValueError, TypeError):
|
||
|
|
return set()
|
||
|
|
|
||
|
|
|
||
|
|
def pipe(left, right, record=None, stopped=lambda: False):
|
||
|
|
"""Preserve chunk ordering; record before forwarding, including binary bytes."""
|
||
|
|
streams = {left: (right, 'guest_to_remote'), right: (left, 'remote_to_guest')}
|
||
|
|
deadline, total, sequence = time.monotonic() + TTL, 0, 0
|
||
|
|
while streams:
|
||
|
|
if stopped():
|
||
|
|
return 'capture_stopped', total
|
||
|
|
if time.monotonic() >= deadline:
|
||
|
|
return 'lifetime_limit', total
|
||
|
|
ready, _, _ = select.select(list(streams), [], [], max(0, min(1, deadline - time.monotonic())))
|
||
|
|
for source in ready:
|
||
|
|
target, direction = streams[source]
|
||
|
|
data = source.recv(min(16384, MAX_STREAM - total + 1))
|
||
|
|
if not data:
|
||
|
|
target.shutdown(socket.SHUT_WR)
|
||
|
|
del streams[source]
|
||
|
|
continue
|
||
|
|
if total + len(data) > MAX_STREAM:
|
||
|
|
if record:
|
||
|
|
record(sequence, direction, data, forwarded=False)
|
||
|
|
return 'byte_limit', total
|
||
|
|
if record:
|
||
|
|
record(sequence, direction, data, forwarded=True)
|
||
|
|
sequence += 1
|
||
|
|
target.sendall(data)
|
||
|
|
total += len(data)
|
||
|
|
return 'eof', total
|
||
|
|
|
||
|
|
|
||
|
|
class Server(socketserver.ThreadingTCPServer):
|
||
|
|
allow_reuse_address = True
|
||
|
|
daemon_threads = True
|
||
|
|
|
||
|
|
def __init__(self, address, handler, **settings):
|
||
|
|
self.__dict__.update(settings)
|
||
|
|
self.slots = threading.BoundedSemaphore(32)
|
||
|
|
self.failed = threading.Event()
|
||
|
|
super().__init__(address, handler)
|
||
|
|
|
||
|
|
def process_request(self, request, address):
|
||
|
|
if self.failed.is_set() or STOP.exists() or not self.slots.acquire(False):
|
||
|
|
request.close()
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
super().process_request(request, address)
|
||
|
|
except BaseException:
|
||
|
|
self.slots.release()
|
||
|
|
raise
|
||
|
|
|
||
|
|
def process_request_thread(self, request, address):
|
||
|
|
try:
|
||
|
|
super().process_request_thread(request, address)
|
||
|
|
finally:
|
||
|
|
self.slots.release()
|
||
|
|
|
||
|
|
|
||
|
|
class Broker(socketserver.BaseRequestHandler):
|
||
|
|
def fail(self):
|
||
|
|
self.server.failed.set()
|
||
|
|
STOP.parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
STOP.touch(mode=0o600, exist_ok=True)
|
||
|
|
|
||
|
|
def event(self, kind, **data):
|
||
|
|
if self.server.failed.is_set() or STOP.exists():
|
||
|
|
raise OSError('evidence failed')
|
||
|
|
try:
|
||
|
|
self.server.evidence.emit(kind, connection_id=self.cid, reported_guest=self.client_address[0], **data)
|
||
|
|
except OSError:
|
||
|
|
self.fail()
|
||
|
|
raise
|
||
|
|
|
||
|
|
def chunk(self, sequence, direction, data, forwarded):
|
||
|
|
try:
|
||
|
|
blob = self.server.evidence.save(data)
|
||
|
|
self.event('network_chunk', sequence=sequence, direction=direction, forwarded=forwarded, **blob)
|
||
|
|
except OSError:
|
||
|
|
self.fail()
|
||
|
|
raise
|
||
|
|
|
||
|
|
def handle(self):
|
||
|
|
self.cid = uuid.uuid4().hex
|
||
|
|
self.request.settimeout(10)
|
||
|
|
connected = False
|
||
|
|
try:
|
||
|
|
message = read_message(self.request)
|
||
|
|
self.event('network_request', untrusted_request=message)
|
||
|
|
if message.get('op') == 'dns':
|
||
|
|
name = message.get('name')
|
||
|
|
if not isinstance(name, str) or len(name) > 253 or not all(c.isascii() and (c.isalnum() or c in '.-') for c in name):
|
||
|
|
raise ValueError('invalid DNS name')
|
||
|
|
addresses = set()
|
||
|
|
for answer in socket.getaddrinfo(name, None, socket.AF_INET, socket.SOCK_STREAM):
|
||
|
|
try:
|
||
|
|
addresses.add(public_ip(answer[4][0]))
|
||
|
|
except ValueError:
|
||
|
|
pass
|
||
|
|
answers = sorted(addresses)[:16]
|
||
|
|
self.event('dns_result', name=name, addresses=answers)
|
||
|
|
send_message(self.request, {'ok': True, 'addresses': answers})
|
||
|
|
return
|
||
|
|
if message.get('op') != 'connect':
|
||
|
|
raise ValueError('unknown operation')
|
||
|
|
ip = public_ip(message.get('ip'))
|
||
|
|
port = message.get('port')
|
||
|
|
if type(port) is not int or not 1 <= port <= 65535:
|
||
|
|
raise ValueError('invalid port')
|
||
|
|
if port not in (80, 443) and ip not in allowed_peers(self.server.peers):
|
||
|
|
raise ValueError('port requires registered attacker peer')
|
||
|
|
# Numeric IP only: DNS cannot rebind between policy and connect.
|
||
|
|
with socket.create_connection((ip, port), timeout=10) as remote:
|
||
|
|
self.event('network_connected', destination_ip=ip, destination_port=port)
|
||
|
|
send_message(self.request, {'ok': True, 'connection_id': self.cid})
|
||
|
|
connected = True
|
||
|
|
reason, count = pipe(self.request, remote, self.chunk, lambda: self.server.failed.is_set() or STOP.exists())
|
||
|
|
self.event('network_end', reason=reason, bytes=count)
|
||
|
|
except (OSError, ValueError, TypeError) as exc:
|
||
|
|
try:
|
||
|
|
self.event('network_closed', reason=str(exc)[:300])
|
||
|
|
if not connected:
|
||
|
|
send_message(self.request, {'ok': False, 'error': 'connection denied or ended'})
|
||
|
|
except OSError:
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class Relay(socketserver.BaseRequestHandler):
|
||
|
|
def handle(self):
|
||
|
|
self.request.settimeout(10)
|
||
|
|
try:
|
||
|
|
original = self.request.getsockopt(socket.SOL_IP, 80, 16) # Linux SO_ORIGINAL_DST
|
||
|
|
port = struct.unpack('!H', original[2:4])[0]
|
||
|
|
ip = socket.inet_ntoa(original[4:8])
|
||
|
|
with socket.create_connection(self.server.broker, timeout=10) as remote:
|
||
|
|
send_message(remote, {'op': 'connect', 'ip': ip, 'port': port})
|
||
|
|
if read_message(remote).get('ok'):
|
||
|
|
pipe(self.request, remote)
|
||
|
|
except (OSError, ValueError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def dns_reply(packet, broker):
|
||
|
|
if len(packet) < 12 or len(packet) > 512:
|
||
|
|
return None
|
||
|
|
ident, flags, questions, _, _, _ = struct.unpack('!6H', packet[:12])
|
||
|
|
if questions != 1 or flags & 0xf800:
|
||
|
|
return None
|
||
|
|
labels, offset = [], 12
|
||
|
|
while offset < len(packet):
|
||
|
|
size = packet[offset]
|
||
|
|
offset += 1
|
||
|
|
if not size:
|
||
|
|
break
|
||
|
|
if size > 63 or offset + size > len(packet):
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
labels.append(packet[offset:offset + size].decode('ascii'))
|
||
|
|
except UnicodeDecodeError:
|
||
|
|
return None
|
||
|
|
offset += size
|
||
|
|
if offset + 4 != len(packet):
|
||
|
|
return None
|
||
|
|
qtype, qclass = struct.unpack('!HH', packet[offset:])
|
||
|
|
addresses, rcode = [], 0
|
||
|
|
if qclass == 1 and qtype == 1:
|
||
|
|
try:
|
||
|
|
with socket.create_connection(broker, timeout=5) as remote:
|
||
|
|
send_message(remote, {'op': 'dns', 'name': '.'.join(labels)})
|
||
|
|
reply = read_message(remote)
|
||
|
|
addresses = [public_ip(a) for a in reply.get('addresses', [])][:16] if reply.get('ok') else []
|
||
|
|
if not addresses:
|
||
|
|
rcode = 3
|
||
|
|
except (OSError, ValueError, TypeError):
|
||
|
|
rcode = 2
|
||
|
|
# Empty answer for AAAA/unsupported types avoids leaking around the broker.
|
||
|
|
header = struct.pack('!6H', ident, 0x8080 | (flags & 0x100) | rcode, 1, len(addresses), 0, 0)
|
||
|
|
answers = b''.join(b'\xc0\x0c' + struct.pack('!HHIH', 1, 1, 30, 4) + socket.inet_aton(ip) for ip in addresses)
|
||
|
|
return header + packet[12:] + answers
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument('--role', choices=('broker', 'relay'), required=True)
|
||
|
|
parser.add_argument('--listen', default='127.0.0.1')
|
||
|
|
parser.add_argument('--port', type=int)
|
||
|
|
parser.add_argument('--broker', default='10.0.2.100')
|
||
|
|
parser.add_argument('--broker-port', type=int, default=19090)
|
||
|
|
parser.add_argument('--dns-port', type=int, default=53)
|
||
|
|
parser.add_argument('--evidence', default='/var/lib/decoy/network')
|
||
|
|
parser.add_argument('--quota-mib', type=int, default=8192)
|
||
|
|
parser.add_argument('--peers', default='/var/lib/decoy/allowed-peers.json')
|
||
|
|
args = parser.parse_args()
|
||
|
|
if args.role == 'broker':
|
||
|
|
evidence = Evidence(args.evidence, quota=args.quota_mib * 1024 ** 2)
|
||
|
|
server = Server((args.listen, args.port or 19090), Broker, evidence=evidence, peers=args.peers)
|
||
|
|
else:
|
||
|
|
broker = (args.broker, args.broker_port)
|
||
|
|
# Bind DNS before serving TCP so a bind failure prevents partial startup.
|
||
|
|
dns = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
|
|
dns.bind(('127.0.0.1', args.dns_port))
|
||
|
|
def serve_dns():
|
||
|
|
while True:
|
||
|
|
packet, peer = dns.recvfrom(513)
|
||
|
|
response = dns_reply(packet, broker)
|
||
|
|
if response:
|
||
|
|
dns.sendto(response, peer)
|
||
|
|
threading.Thread(target=serve_dns, daemon=True).start()
|
||
|
|
server = Server((args.listen, args.port or 15001), Relay, broker=broker)
|
||
|
|
with server:
|
||
|
|
server.serve_forever()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
main()
|