Add local PS5 guide DNS gateway
This commit is contained in:
@@ -10,5 +10,6 @@ app/.codex
|
||||
app/LICENSE
|
||||
app/README.md
|
||||
app/serve.py
|
||||
docs
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+13
@@ -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 \
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
+14
-8
@@ -68,6 +68,11 @@ kadinho <[email protected]>
|
||||
- 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`.
|
||||
|
||||
|
||||
+40
-20
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user