diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..59d954f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +FROM base AS python-deps + +# Install pipenv and compilation dependencies +RUN pip install pipenv + +# Install python dependencies in /.venv +COPY Pipfile Pipfile.lock ./ +RUN PIPENV_VENV_IN_PROJECT=1 pipenv install --deploy + + +FROM base AS runtime + +# Copy virtual env from python-deps stage +COPY --from=python-deps /.venv /.venv +ENV PATH="/.venv/bin:$PATH" + +# Install application into container +COPY . . + +# Run the application +CMD ["./pod.py"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5776591 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Kube-escape + +Exfiltrates a Kubernetes API server over a websocket connection. + + +This is useful when needing to connect to internal clusters where the API is +only reachable via a VPN you don't have access to, or via a slow Windows citrix VM. + +This works by running a pod inside the kubernetes (or AKS, or ECS, or GKE, or..) cluster. + +The pod needs the two following requirements: + +- It should be able to talk with the Interweb (a webserver your control), on HTTP or HTTPs +- It should be able to talk with the kubernetes API (supposing that it is not filtered some way) + + +It will then create a websocket connection to your webserver (running the [proxy.py](proxy.py) proxy application). + +In order to reach the k8s api from your non-corporate-approved laptop, you can use the [client.py](client.py) client. +You need to provide the websocket link given by the pod. Once launched, a bidirectional TCP socket will +be created from your machine to the kubernetes api, going through the websocket proxy, and the undercover pod. + +Of course, you still need to have valid credentials, through a kubeconfig file + + +You'll need to edit the kubeconfig file and change the api host to be your localhost. + + + +### Security + +I guess you could proxy your websockets through an HTTPs endpoint. Wouldn't be bad. +However, the kubeapi proto is already over TLS, so it wouldn't add much value. + +### Compression + +Sadly it's not really possible (efficient-wise) to compress TLS data as it looks +random-ish. \ No newline at end of file diff --git a/client.py b/client.py old mode 100644 new mode 100755 index a34cdfb..d614255 --- a/client.py +++ b/client.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import asyncio import websockets import hashlib @@ -22,6 +23,7 @@ async def handle_client(socket_reader, socket_writer): try: print(f"New client connected socket {socket_id}") m = conn.WSMsg(socket_id, conn.MsgType.CONNECT) + print(f'TCP>WS: {m}') await websocket.send(m.to_bytes()) # Forwarding data from client to WebSocket @@ -31,6 +33,7 @@ async def handle_client(socket_reader, socket_writer): if not data: c = conn.WSMsg(socket_id, conn.MsgType.DISCONNECT) print(f"Client {socket_id} disconnected") + print(f'TCP>WS: {c}') await websocket.send(c.to_bytes()) break @@ -45,7 +48,9 @@ async def handle_client(socket_reader, socket_writer): c = conn.WSMsg.from_bytes(message) # XXX this is ugly, because it means that the data is sent twice or more if 2+ connections.. + print('c.socketid', c.socketid, 'socket_id', socket_id) if c.socketid == socket_id: + print('ours') if c.msg == conn.MsgType.DISCONNECT: print(f"Client {socket_id} disconnected") break @@ -57,6 +62,7 @@ async def handle_client(socket_reader, socket_writer): socket_writer.write(c.payload) await socket_writer.drain() else: + print('not ours') print(f'WS>TCP@{socket_id}: ', hashlib.md5(message).hexdigest(), 'skipping') diff --git a/pod.py b/pod.py old mode 100644 new mode 100755 index dbc6295..df8bbe3 --- a/pod.py +++ b/pod.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 import asyncio import websockets import uuid @@ -15,6 +16,7 @@ async def handle_socket_read(socketid, tcpreader, ws): print(f"New socket: {socketid}. Waiting on recv") while True: data = await tcpreader.read(2024) + print(f"TCP@{socketid} Received {len(data)} bytes") if data == b'': print(f"TCP@{socketid} Connection closed") c = conn.WSMsg(socketid, conn.MsgType.DISCONNECT) @@ -62,6 +64,7 @@ async def handle_ws_incoming(cfg, ws, sockets): tcpreader, tcpwriter = sockets[socketid] print(f'WS>TCP: {c}') tcpwriter.write(c.payload) + print('written') def get_config(): diff --git a/proxy.py b/proxy.py old mode 100644 new mode 100755 index 9ecd140..2349b2b --- a/proxy.py +++ b/proxy.py @@ -1,21 +1,26 @@ +#!/usr/bin/env python3 import asyncio import websockets import hashlib +import os import websockets.asyncio.server # List to store connected clients -connected_clients = set() +connected_clients: dict[str, set] = dict() async def handler(websocket): # Register the new client print(f"New client connected: {websocket}") print(f"WRP: {websocket.request.path}") - connected_clients.add(websocket) + if websocket.request.path not in connected_clients: + connected_clients[websocket.request.path] = set() + connected_clients[websocket.request.path].add(websocket) + try: async for message in websocket: - # Forward the message to all connected clients - for client in connected_clients: + # Forward the message to all connected clients on the same path + for client in connected_clients[websocket.request.path]: if client != websocket: print(f"WS>WS: ", hashlib.md5(message).hexdigest()) await client.send(message) @@ -28,8 +33,9 @@ async def handler(websocket): async def main(): # Start the WebSocket server - server = await websockets.asyncio.server.serve(handler, "localhost", 9999) - print("WebSocket server listening on ws://localhost:9999") + ws_port = os.environ.get("WS_PORT", 9999) + server = await websockets.asyncio.server.serve(handler, "::", ws_port) + print(f"WebSocket server listening on ws://[::]:{ws_port}") await server.wait_closed() if __name__ == "__main__":