From 0017ea8fd4f8e7e5359ef3112b5c2ddca51860a0 Mon Sep 17 00:00:00 2001 From: kadinho Date: Tue, 29 Sep 2026 23:41:52 +0100 Subject: [PATCH] Add local PS5 guide DNS gateway --- .dockerignore | 1 + AGENTS.md | 4 + Dockerfile | 13 +++ README.md | 4 + compose.yaml | 26 +++++ docker/guide_gateway.py | 204 ++++++++++++++++++++++++++++++++++++++++ docs/HANDOFF.md | 22 +++-- docs/PS5_SETUP.md | 60 ++++++++---- 8 files changed, 306 insertions(+), 28 deletions(-) create mode 100644 docker/guide_gateway.py diff --git a/.dockerignore b/.dockerignore index 82e9a94..c09088c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,5 +10,6 @@ app/.codex app/LICENSE app/README.md app/serve.py +docs **/__pycache__ **/*.pyc diff --git a/AGENTS.md b/AGENTS.md index d2a4b23..2dc5c6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ before including them in an image. - `Dockerfile`: runtime image and payload-integrity checks. - `compose.yaml`: hardened local deployment. - `docker/server.py`: static HTTP server and security headers. +- `docker/guide_gateway.py`: restricted DNS plus HTTP/HTTPS User Guide redirect. - `payloads.sha256`: approved payload digests for the pinned submodule commit. - `docs/PS5_SETUP.md`: user and operator guide. - `docs/HANDOFF.md`: current infrastructure state and durable session handoff. @@ -69,6 +70,9 @@ Then perform a disposable smoke test and verify: - `/.git/config` returns 404; - the process runs as UID/GID `65532:65532`; - the configured security headers remain present. +- `manuals.playstation.net` resolves to the configured gateway address; +- unrelated DNS names return NXDOMAIN; +- HTTP and HTTPS User Guide requests redirect to the local exploit URL. Do not leave smoke-test containers running. diff --git a/Dockerfile b/Dockerfile index f4e4d83..b1ffe34 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,15 @@ ARG PYTHON_IMAGE=python:3.14-alpine +FROM ${PYTHON_IMAGE} AS certificates + +RUN apk add --no-cache openssl \ + && install -d -m 0755 /certificates \ + && openssl req -x509 -newkey rsa:2048 -sha256 -nodes -days 3650 \ + -subj "/CN=manuals.playstation.net" \ + -addext "subjectAltName=DNS:manuals.playstation.net" \ + -keyout /certificates/manuals.key \ + -out /certificates/manuals.crt \ + && chmod 0600 /certificates/manuals.key + FROM ${PYTHON_IMAGE} ENV PYTHONDONTWRITEBYTECODE=1 \ @@ -13,6 +24,8 @@ COPY app/src /srv/public/src COPY app/offsets /srv/public/offsets COPY app/payloads /srv/public/payloads COPY docker/server.py /srv/server.py +COPY docker/guide_gateway.py /srv/guide_gateway.py +COPY --from=certificates /certificates /srv/certificates RUN cd /srv/public && sha256sum -c /tmp/payloads.sha256 \ && rm /tmp/payloads.sha256 \ diff --git a/README.md b/README.md index 7d85257..f2b7541 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Container packaging for [PS5 Relapse Exploit](https://github.com/ntfargo/Relapse The upstream project is pinned as a Git submodule, and the image build verifies the SHA-256 digest of every executable payload before including it. +The Compose stack also includes a local PS5 User Guide gateway: authoritative DNS +for `manuals.playstation.net`, HTTP/HTTPS redirect to the local exploit, and NXDOMAIN +for every other domain so the console cannot resolve PSN or update services. + Use this only on consoles you own or are authorized to test. The exploit can panic the console, corrupt kernel state, expose administrative services, and execute arbitrary code with elevated privileges. diff --git a/compose.yaml b/compose.yaml index 3284279..3ef67ae 100644 --- a/compose.yaml +++ b/compose.yaml @@ -13,3 +13,29 @@ services: security_opt: - no-new-privileges:true restart: unless-stopped + + ps5-guide-gateway: + image: relapse-exploit:local + command: ["python", "/srv/guide_gateway.py"] + environment: + GATEWAY_IP: "${GATEWAY_IP:-192.168.20.30}" + REDIRECT_URL: "${GUIDE_REDIRECT_URL:-http://192.168.20.30:8000/}" + ports: + - "${GATEWAY_BIND_IP:-192.168.20.30}:53:8053/udp" + - "${GATEWAY_BIND_IP:-192.168.20.30}:53:8053/tcp" + - "${GATEWAY_BIND_IP:-192.168.20.30}:80:8081/tcp" + - "${GATEWAY_BIND_IP:-192.168.20.30}:443:8443/tcp" + read_only: true + tmpfs: + - /tmp:size=1m,mode=1777 + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8081/_health', timeout=2).close()"] + interval: 30s + timeout: 3s + start_period: 5s + retries: 3 + restart: unless-stopped diff --git a/docker/guide_gateway.py b/docker/guide_gateway.py new file mode 100644 index 0000000..cdfbb7d --- /dev/null +++ b/docker/guide_gateway.py @@ -0,0 +1,204 @@ +import http.server +import ipaddress +import os +import signal +import socket +import socketserver +import ssl +import struct +import threading + + +DNS_PORT = int(os.environ.get("DNS_PORT", "8053")) +HTTP_PORT = int(os.environ.get("HTTP_PORT", "8081")) +HTTPS_PORT = int(os.environ.get("HTTPS_PORT", "8443")) +GATEWAY_IP = ipaddress.IPv4Address(os.environ.get("GATEWAY_IP", "192.168.20.30")) +REDIRECT_URL = os.environ.get("REDIRECT_URL", f"http://{GATEWAY_IP}:8000/") +TARGET_DOMAIN = "manuals.playstation.net" +CERTIFICATE = "/srv/certificates/manuals.crt" +PRIVATE_KEY = "/srv/certificates/manuals.key" + + +def parse_question(packet): + if len(packet) < 17: + raise ValueError("short DNS query") + + labels = [] + offset = 12 + while True: + if offset >= len(packet): + raise ValueError("unterminated DNS name") + length = packet[offset] + offset += 1 + if length == 0: + break + if length & 0xC0 or length > 63 or offset + length > len(packet): + raise ValueError("unsupported DNS name") + labels.append(packet[offset : offset + length].decode("ascii")) + offset += length + + if offset + 4 > len(packet): + raise ValueError("short DNS question") + qtype, qclass = struct.unpack_from("!HH", packet, offset) + return ".".join(labels).lower(), qtype, qclass, offset + 4 + + +def dns_response(packet): + if len(packet) < 12: + return b"" + + transaction_id, request_flags, question_count = struct.unpack_from("!HHH", packet, 0) + response_flags = 0x8400 | (request_flags & 0x0100) # response, authoritative, preserve RD + try: + name, qtype, qclass, question_end = parse_question(packet) + if question_count != 1: + raise ValueError("expected one DNS question") + except (UnicodeDecodeError, ValueError): + return struct.pack("!HHHHHH", transaction_id, response_flags | 1, 0, 0, 0, 0) + + question = packet[12:question_end] + if name != TARGET_DOMAIN: + return struct.pack("!HHHHHH", transaction_id, response_flags | 3, 1, 0, 0, 0) + question + + if qclass == 1 and qtype in (1, 255): + answer = ( + b"\xc0\x0c" + + struct.pack("!HHIH", 1, 1, 60, 4) + + GATEWAY_IP.packed + ) + return struct.pack("!HHHHHH", transaction_id, response_flags, 1, 1, 0, 0) + question + answer + + # NOERROR with no data prevents IPv6 or unsupported record fallback. + return struct.pack("!HHHHHH", transaction_id, response_flags, 1, 0, 0, 0) + question + + +class ThreadingUDPServer(socketserver.ThreadingUDPServer): + allow_reuse_address = True + daemon_threads = True + + +class ThreadingTCPServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +class UDPDNSHandler(socketserver.BaseRequestHandler): + def handle(self): + packet, connection = self.request + try: + name, qtype, _, _ = parse_question(packet) + print(f"DNS UDP {self.client_address[0]} {name} type={qtype}", flush=True) + except (UnicodeDecodeError, ValueError): + print(f"DNS UDP {self.client_address[0]} malformed", flush=True) + response = dns_response(packet) + if response: + connection.sendto(response, self.client_address) + + +class TCPDNSHandler(socketserver.BaseRequestHandler): + def handle(self): + length_bytes = self._receive_exactly(2) + if length_bytes is None: + return + wanted = struct.unpack("!H", length_bytes)[0] + packet = self._receive_exactly(wanted) + if packet is None: + return + try: + name, qtype, _, _ = parse_question(packet) + print(f"DNS TCP {self.client_address[0]} {name} type={qtype}", flush=True) + except (UnicodeDecodeError, ValueError): + print(f"DNS TCP {self.client_address[0]} malformed", flush=True) + response = dns_response(packet) + if response: + self.request.sendall(struct.pack("!H", len(response)) + response) + + def _receive_exactly(self, wanted): + chunks = [] + received = 0 + while received < wanted: + chunk = self.request.recv(wanted - received) + if not chunk: + return None + chunks.append(chunk) + received += len(chunk) + return b"".join(chunks) + + +class RedirectHandler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def do_GET(self): + if self.path == "/_health": + body = b"ok\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + self.send_response(302) + self.send_header("Location", REDIRECT_URL) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + + def do_HEAD(self): + self.send_response(302) + self.send_header("Location", REDIRECT_URL) + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, message, *args): + user_agent = self.headers.get("User-Agent", "-") + print( + f"HTTP {self.client_address[0]} {message % args} user-agent={user_agent!r}", + flush=True, + ) + + +class HTTPServer(http.server.ThreadingHTTPServer): + allow_reuse_address = True + daemon_threads = True + + +def serve(server, name): + thread = threading.Thread(target=server.serve_forever, name=name, daemon=True) + thread.start() + return thread + + +if __name__ == "__main__": + dns_udp = ThreadingUDPServer(("0.0.0.0", DNS_PORT), UDPDNSHandler) + dns_tcp = ThreadingTCPServer(("0.0.0.0", DNS_PORT), TCPDNSHandler) + http = HTTPServer(("0.0.0.0", HTTP_PORT), RedirectHandler) + https = HTTPServer(("0.0.0.0", HTTPS_PORT), RedirectHandler) + + tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls.load_cert_chain(CERTIFICATE, PRIVATE_KEY) + https.socket = tls.wrap_socket(https.socket, server_side=True) + + servers = [dns_udp, dns_tcp, http, https] + serve(dns_udp, "dns-udp") + serve(dns_tcp, "dns-tcp") + serve(http, "http-redirect") + serve(https, "https-redirect") + + print( + f"PS5 guide gateway: {TARGET_DOMAIN} -> {GATEWAY_IP}; redirect -> {REDIRECT_URL}", + flush=True, + ) + + stopped = threading.Event() + + def stop(*_): + stopped.set() + + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + stopped.wait() + for server in servers: + server.shutdown() + server.server_close() diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 3b5b91f..19eef2b 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -68,6 +68,11 @@ kadinho - container port: `8080/TCP`; - restart policy: `unless-stopped`. +The same Compose project also runs `ps5-guide-gateway`, bound specifically to +`192.168.20.30` on DNS UDP/TCP 53 and HTTP/HTTPS TCP 80/443. It resolves only +`manuals.playstation.net`, returns NXDOMAIN for every other name, and redirects the +User Guide to `http://192.168.20.30:8000/`. + Port `8080` on the host is already used by `unifi-network-application`; do not move this service there. At the last verification, the Relapse container was healthy, ran as `65532:65532`, used a read-only root filesystem, and returned HTTP 200 over @@ -133,15 +138,16 @@ The local HTTP endpoint is available and verified at: http://192.168.20.30:8000/ ``` -The upstream project recommends public primary DNS `45.56.67.85`. The user reported -successful access through the User Guide. No DNS container was installed on -`net-utils01`, and this repository's Compose publishes no DNS service. +The upstream public DNS `45.56.67.85` was used initially, but the PS5 did not contact +the local container. The local gateway was therefore added. Configure the PS5 with +primary DNS `192.168.20.30` and secondary DNS `0.0.0.0`, then restart the console and +accept the self-signed certificate warning in the User Guide. -A local DNS service and an HTTP/HTTPS reverse proxy for the PS5 User Guide are -possible future improvements, but must be designed separately. DNS only maps names -to IP addresses; it cannot redirect the User Guide from ports 80/443 to port 8000. -Before binding ports 53, 80, or 443 on `net-utils01`, inspect for conflicts and agree -on the desired DNS forwarding and logging policy. +The gateway intentionally provides no recursive DNS forwarding. Only +`manuals.playstation.net` resolves; all other names receive NXDOMAIN. This blocks +PSN and update resolution and keeps the setup independent of a public DNS operator. +The PS5 was identified as `192.168.10.26`; requests from `192.168.10.6` seen earlier +were from a different client. Full PS5 instructions and DNS options are in `docs/PS5_SETUP.md`. diff --git a/docs/PS5_SETUP.md b/docs/PS5_SETUP.md index fd3d7c7..6d3c039 100644 --- a/docs/PS5_SETUP.md +++ b/docs/PS5_SETUP.md @@ -18,6 +18,8 @@ Na rede onde este projeto foi inicialmente instalado: - imagem: `relapse-exploit:local`; - porta interna: `8080`; - porta publicada: `8000`. +- DNS local: `192.168.20.30:53` (UDP e TCP); +- gateway do Guia: `192.168.20.30:80` e `192.168.20.30:443`. A porta 8080 do host já pertence ao UniFi e não deve ser reutilizada. @@ -127,15 +129,17 @@ Um DNS público implica confiar no respetivo operador. Pode ficar indisponível, alterar o destino do Guia do Utilizador ou ser contornado por um ISP que intercepte DNS. Confirmar sempre a página e o endereço apresentados antes de executar. -### Opção 3: DNS local +### Opção 3: DNS local (configuração instalada) -A solução preferível a longo prazo é um DNS controlado na LAN, por exemplo no -`net-utils01`, que: +O Compose inclui um DNS controlado no `net-utils01` que: - bloqueie domínios PlayStation/PSN e servidores de atualização; -- encaminhe as restantes consultas apenas se isso for desejado; -- redirecione `manuals.playstation.net` para o host de exploit apropriado; -- registe consultas para permitir auditoria. +- responda `manuals.playstation.net` com `192.168.20.30`; +- devolva `NXDOMAIN` para todos os restantes nomes, bloqueando PSN, updates, + telemetria e acesso geral à Internet por DNS; +- registe as consultas nos logs do container; +- apresente um gateway HTTPS local e redirecione o browser para o exploit em + `http://192.168.20.30:8000/`. Nesse cenário, a PS5 usaria: @@ -144,14 +148,15 @@ DNS primário: 192.168.20.30 DNS secundário: 0.0.0.0 ``` -O serviço DNS não faz parte deste Compose e deve ser instalado e auditado -separadamente. Também é possível bloquear o acesso WAN da PS5 no router, mantendo -apenas a comunicação LAN; esta é uma camada de proteção mais forte do que DNS. +Depois de guardar o DNS na PS5, reiniciar completamente a consola se o Guia continuar +a usar uma resolução antiga. Abrir o Guia e aceitar o aviso de certificado não +confiável: o certificado é self-signed para `manuals.playstation.net` e existe apenas +para permitir que o Guia chegue ao redirecionamento local. -DNS só traduz nomes para endereços IP; não redireciona portas. Para servir o Guia do -Utilizador a partir do host local, é também necessário disponibilizar o site nas -portas HTTP/HTTPS esperadas pelo manual ou colocar um reverse proxy à frente da -porta 8000. O Compose atual publica apenas HTTP em `8000`. +O gateway liga-se por defeito apenas ao endereço `192.168.20.30`. Para outra rede, +definir `GATEWAY_BIND_IP`, `GATEWAY_IP` e `GUIDE_REDIRECT_URL` antes de executar o +Compose. Também é possível bloquear o acesso WAN da PS5 no router, mantendo apenas +a comunicação LAN; esta continua a ser uma camada adicional mais forte do que DNS. ## Abrir pelo Guia do Utilizador @@ -161,11 +166,16 @@ Com um DNS que redirecione o manual: 2. abrir `Guia do Utilizador, saúde e segurança e outras informações`; 3. abrir `Guia do Utilizador`; 4. confirmar que aparece a página Relapse esperada; -5. se for importante usar a instalação local, confirmar que o destino é - `192.168.20.30:8000` e não um host público inesperado. +5. aceitar o aviso de certificado self-signed, se aparecer; +6. confirmar nos logs que o cliente `192.168.10.26` consultou o DNS e abriu o + gateway; +7. confirmar que o destino final é `192.168.20.30:8000`. -O DNS público pode abrir diretamente uma página de exploit própria. Isso não prova -que a página veio do container local. +Ver os logs do gateway: + +```sh +docker compose logs -f ps5-guide-gateway +``` ## Executar o exploit @@ -193,6 +203,8 @@ uma mensagem genérica de conclusão prova que todas as etapas terminaram corret ## Portas e serviços posteriores - `8000/TCP` no servidor: página Relapse; +- `53/UDP` e `53/TCP` no servidor: DNS local restritivo; +- `80/TCP` e `443/TCP` no servidor: gateway do Guia do Utilizador; - `9021/TCP` na PS5: ELF loader; - `10101/TCP` na PS5: API HTTP do ShadowMount+, por defeito em loopback. @@ -259,9 +271,17 @@ impede comunicação com `192.168.20.30`. ### O Guia do Utilizador abre a página oficial ou outro host -O DNS configurado não está a redirecionar `manuals.playstation.net` para o destino -esperado, ou o ISP/router está a interferir com DNS. Isto é independente do container -HTTP. Usar o URL local diretamente ou instalar um DNS local controlado. +Confirmar que o DNS primário da PS5 é `192.168.20.30`, que não existe um DNS +secundário normal e que o gateway está saudável: + +```sh +docker compose ps +docker compose logs --tail=100 ps5-guide-gateway +``` + +Depois reiniciar completamente a consola para limpar resoluções antigas. Se não +existir qualquer consulta proveniente do IP da PS5, verificar regras entre as VLANs +`192.168.10.0/24` e `192.168.20.0/24`, incluindo UDP/TCP 53 e TCP 80/443/8000. ### O build falha nos hashes