Seregon/zftpd

Zero-copy FTP/HTTP Daemon compatible with all POSIX systems

C/11.0 KB/No license
src/ps5_net_filter.c
zftpd / src / ps5_net_filter.c
1/*
2 * MIT License — Copyright (c) 2026 SeregonWar
3 * See LICENSE for full text.
4 */
5 
6/**
7 * @file ps5_net_filter.c
8 * @brief PS5 kernel-level outbound connection filter — implementation
9 *
10 * IMPLEMENTATION NOTES
11 * --------------------
12 *
13 * 1. KERNEL HOOK PAGE
14 * The hook function cannot reside in userland memory and be called from
15 * kernel context. We mmap() a userland page, then use kernel_copyin()
16 * to write both the hook machine code and the filter configuration (rule
17 * table + PID + original function pointer) into a kernel-executable page
18 * obtained via kernel_alloc_exec_page().
19 *
20 * Layout of the kernel hook page (4096 bytes):
21 *
22 * [0x000 – 0x3FF] hook_sys_connect() machine code (~200 bytes)
23 * [0x400 – 0x47F] hook_sys_sendto() machine code (~100 bytes)
24 * [0x480 – 0x4FF] ps5_hook_shared_t (config block) (128 bytes)
25 *
26 * The config block is accessed by the hooks via a RIP-relative address
27 * baked in at install time. No pointers to userland memory appear in
28 * the kernel page.
29 *
30 * 2. SYSENT PATCHING
31 * FreeBSD sysent entry (amd64, FreeBSD 11):
32 *
33 * struct sysent {
34 * int sy_narg; // +0x00 (4 bytes)
35 * int sy_pad; // +0x04 (4 bytes)
36 * u_int32_t sy_flags; // +0x08 (4 bytes)
37 * u_int32_t _pad2; // +0x0C (4 bytes)
38 * sy_call_t *sy_call; // +0x10 (8 bytes) ← we patch this
39 * au_event_t sy_auevent; // +0x18 (2 bytes)
40 * ...
41 * }; // sizeof = 72 bytes on amd64
42 *
43 * sysent[SYS_CONNECT] = sysent_base + 98 * 72
44 * sysent[SYS_SENDTO] = sysent_base + 133 * 72
45 * sy_call offset within entry = 0x10
46 *
47 * 3. HOOK FUNCTION DESIGN
48 * The hook runs in kernel context (supervisor mode, kernel stack).
49 * Constraints:
50 * - No userland pointer dereferences.
51 * - No dynamic memory allocation (no malloc, no kmalloc).
52 * - Must save/restore all callee-saved registers (handled by C ABI
53 * since the hook is a normal C function from the compiler's view).
54 * - Must use kernel calling convention (same as normal sy_call).
55 *
56 * Filter logic:
57 * a) If td->td_proc->p_pid == g_shared.zftpd_pid → allow (fast path)
58 * b) Extract destination IP from sockaddr argument via copyin()
59 * c) If dest IP is RFC-1918 or loopback → allow
60 * d) If dest IP matches any rule in g_shared.rules[] → ENETUNREACH
61 * e) Otherwise → allow
62 *
63 * 4. FIRMWARE TABLE
64 * The sysent table base address is firmware-specific (KASLR randomises
65 * the kernel base, but the *relative offset* of sysent from kernel_base
66 * is fixed per firmware version). We maintain a table of these offsets
67 * for all supported firmware versions.
68 *
69 * Offsets are derived from public PS5 kernel symbol dumps and verified
70 * against known kstuff research.
71 *
72 * @note Compiled only when PLATFORM_PS5 is defined.
73 */
74 
75#ifdef PLATFORM_PS5
76 
77#include "ps5_net_filter.h"
78#include "ftp_log.h"
79#include "pal_notification.h"
80 
81#include <errno.h>
82#include <stdatomic.h>
83#include <stddef.h>
84#include <stdint.h>
85#include <stdio.h>
86#include <string.h>
87#include <sys/mman.h>
88#include <sys/syscall.h>
89#include <sys/sysctl.h>
90#include <unistd.h>
91 
92/* PS5 payload SDK kernel primitives */
93#include <ps5/kernel.h>
94 
95/*=======================================================================*
96 * KERNEL HELPERS — built on top of ps5-payload-sdk primitives
97 *
98 * The SDK header <ps5/kernel.h> provides:
99 * KERNEL_ADDRESS_TEXT_BASE (intptr_t, kernel .text base)
100 * kernel_copyout / kernel_setlong (arbitrary kernel r/w)
101 * kernel_mprotect / kernel_set_vmem_protection
102 * kernel_get_proc / KERNEL_OFFSET_PROC_P_VMSPACE
103 *
104 * We build three helpers the net filter requires:
105 * kernel_get_base() → KERNEL_ADDRESS_TEXT_BASE
106 * kernel_get_phys_addr() → CR3 page-table walk
107 * kernel_clear_pte_nx() → SDK mprotect wrappers
108 *=======================================================================*/
109 
110/* FreeBSD amd64 direct-map base (physical → kernel VA) */
111#define DMAP_BASE_ADDR 0xFFFF800000000000ULL
112 
113/* x86-64 PTE flags */
114#define PTE_PRESENT (1ULL << 0)
115#define PTE_PS (1ULL << 7)
116#define PTE_NX (1ULL << 63)
117#define PTE_ADDR_MASK 0x000FFFFFFFFFF000ULL /* bits [51:12] */
118 
119/**
120 * @brief Return the kernel .text base address.
121 */
122static inline uint64_t kernel_get_base(void) {
123 return (uint64_t)KERNEL_ADDRESS_TEXT_BASE;
124}
125 
126/**
127 * @brief Read one 8-byte PTE via the DMAP region.
128 */
129static int read_pte(uint64_t pte_phys, uint64_t *pte_out) {
130 intptr_t kva = (intptr_t)(DMAP_BASE_ADDR + pte_phys);
131 return kernel_copyout(kva, pte_out, sizeof(*pte_out));
132}
133 
134/**
135 * @brief Resolve a virtual address to its physical page address.
136 *
137 * Walks the 4-level page table (PML4 → PDPT → PD → PT) using
138 * kernel r/w primitives.
139 *
140 * VA breakdown (4 KB pages):
141 *
142 * 63 48 47 39 38 30 29 21 20 12 11 0
143 * ┌──────┬──────┬──────┬──────┬──────┬─────────┐
144 * │ sign │ PML4 │ PDPT │ PD │ PT │ offset │
145 * └──────┴──────┴──────┴──────┴──────┴─────────┘
146 *
147 * @param[in] va Virtual address.
148 * @param[out] pa_out Physical address.
149 * @return 0 on success, -1 on failure.
150 */
151static int kernel_get_phys_addr(uintptr_t va, uint64_t *pa_out) {
152 if (pa_out == NULL) {
153 return -1;
154 }
155 
156 /* DMAP addresses: phys = VA - DMAP_BASE */
157 if ((uint64_t)va >= DMAP_BASE_ADDR) {
158 *pa_out = (uint64_t)va - DMAP_BASE_ADDR;
159 return 0;
160 }
161 
162 /* Userland VA: read CR3 from the current process's pmap.
163 * proc → p_vmspace → vm_pmap.pm_cr3 (offset 0x10 in pmap) */
164 intptr_t proc_kaddr = kernel_get_proc(getpid());
165 if (proc_kaddr == 0) {
166 return -1;
167 }
168 
169 intptr_t vmspace_ptr = 0;
170 if (kernel_copyout(proc_kaddr + (intptr_t)KERNEL_OFFSET_PROC_P_VMSPACE,
171 &vmspace_ptr, sizeof(vmspace_ptr)) != 0) {
172 return -1;
173 }
174 if (vmspace_ptr == 0) {
175 return -1;
176 }
177 
178 uint64_t cr3 = 0;
179 if (kernel_copyout(vmspace_ptr + 0x10, &cr3, sizeof(cr3)) != 0) {
180 return -1;
181 }
182 
183 uint64_t v = (uint64_t)va;
184 
185 /* ── PML4 ── */
186 uint64_t pml4_phys = (cr3 & PTE_ADDR_MASK) + (((v >> 39) & 0x1FFULL) * 8ULL);
187 uint64_t pml4e = 0;
188 if (read_pte(pml4_phys, &pml4e) != 0 || !(pml4e & PTE_PRESENT)) {
189 return -1;
190 }
191 
192 /* ── PDPT ── */
193 uint64_t pdpt_phys =
194 (pml4e & PTE_ADDR_MASK) + (((v >> 30) & 0x1FFULL) * 8ULL);
195 uint64_t pdpte = 0;
196 if (read_pte(pdpt_phys, &pdpte) != 0 || !(pdpte & PTE_PRESENT)) {
197 return -1;
198 }
199 if (pdpte & PTE_PS) { /* 1 GB huge page */
200 *pa_out = (pdpte & 0x000FFFFFC0000000ULL) | (v & 0x3FFFFFFFULL);
201 return 0;
202 }
203 
204 /* ── PD ── */
205 uint64_t pd_phys = (pdpte & PTE_ADDR_MASK) + (((v >> 21) & 0x1FFULL) * 8ULL);
206 uint64_t pde = 0;
207 if (read_pte(pd_phys, &pde) != 0 || !(pde & PTE_PRESENT)) {
208 return -1;
209 }
210 if (pde & PTE_PS) { /* 2 MB large page */
211 *pa_out = (pde & 0x000FFFFFFFE00000ULL) | (v & 0x1FFFFFULL);
212 return 0;
213 }
214 
215 /* ── PT ── */
216 uint64_t pt_phys = (pde & PTE_ADDR_MASK) + (((v >> 12) & 0x1FFULL) * 8ULL);
217 uint64_t pte = 0;
218 if (read_pte(pt_phys, &pte) != 0 || !(pte & PTE_PRESENT)) {
219 return -1;
220 }
221 
222 *pa_out = (pte & PTE_ADDR_MASK) | (v & 0xFFFULL);
223 return 0;
224}
225 
226/**
227 * @brief Clear the NX bit for a kernel VA via SDK mprotect wrappers.
228 *
229 * @param[in] va Kernel virtual address to make executable.
230 * @return 0 on success, -1 on failure.
231 */
232static int kernel_clear_pte_nx(uintptr_t va) {
233 int prot = 0x07; /* PROT_READ | PROT_WRITE | PROT_EXEC */
234 
235 if (kernel_mprotect(getpid(), (intptr_t)va, (size_t)4096U, prot) == 0) {
236 return 0;
237 }
238 
239 if (kernel_set_vmem_protection(getpid(), (intptr_t)va, (size_t)4096U, prot) ==
240 0) {
241 return 0;
242 }
243 
244 return -1;
245}
246 
247/*===========================================================================*
248 * INTERNAL CONSTANTS
249 *===========================================================================*/
250 
251/** FreeBSD amd64 sizeof(struct sysent) */
252#define SYSENT_ENTRY_SIZE 72U
253 
254/** Offset of sy_call within struct sysent */
255#define SYSENT_SY_CALL_OFFSET 0x10U
256 
257/** Syscall numbers (FreeBSD) */
258#define SYS_CONNECT 98U
259#define SYS_SENDTO 133U
260 
261/** Size of the kernel hook page (one page is enough) */
262#define HOOK_PAGE_SIZE 4096U
263 
264/**
265 * Offsets within the hook page.
266 *
267 * DESIGN RATIONALE: Placing both hook functions and the shared config block
268 * in the same page means a single kernel_copyin() installs everything.
269 * The config block is addressed via a fixed page-relative offset baked into
270 * the hook code at install time (see hook_shared_kaddr below).
271 */
272#define HOOK_CONNECT_CODE_OFFSET 0x000U /**< hook_sys_connect() code */
273#define HOOK_SENDTO_CODE_OFFSET 0x400U /**< hook_sys_sendto() code */
274#define HOOK_SHARED_DATA_OFFSET 0x480U /**< ps5_hook_shared_t data */
275 
276/*===========================================================================*
277 * FIRMWARE SYSENT TABLE
278 *===========================================================================*/
279 
280/**
281 * Per-firmware sysent offset from kernel_base.
282 *
283 * HOW THESE WERE OBTAINED
284 * -----------------------
285 * Each entry is derived from:
286 * 1. PS5 kernel ELF symbol dump (available for FW 1.xx – 4.xx via WebKit
287 * exploits that leak the kernel .text segment).
288 * 2. Pattern scanning for the first 8 sysent entries (known syscall indices
289 * 0–7 have stable arg counts: nosys=0, exit=1, fork=0, read=3, ...).
290 * 3. Cross-referenced against ps5-kstuff offset tables.
291 *
292 * TO ADD A NEW FIRMWARE
293 * ---------------------
294 * 1. Obtain kernel_base for that firmware (via the exploit chain).
295 * 2. Pattern-scan for `sysent`:
296 * uint64_t sysent_pattern[] = { 0x00000000, 0x00000001, ... };
297 * // sysent[0].sy_narg=0 (nosys), sysent[1].sy_narg=1 (exit), ...
298 * 3. Add the (fw_version, sysent_offset) pair to the table below.
299 *
300 * @note fw_version encoding: major * 100 + minor (e.g. 4.03 → 403)
301 */
302typedef struct {
303 uint32_t fw_version; /**< Encoded firmware version (e.g. 403 = 4.03) */
304 uint64_t sysent_offset; /**< sysent[] offset from kernel_base */
305 uint64_t thread_proc_off; /**< td->td_proc offset within struct thread */
306 uint64_t proc_pid_off; /**< p_pid offset within struct proc */
307} ps5_fw_entry_t;
308 
309/**
310 * Firmware support table.
311 *
312 * IMPORTANT: All offsets must be verified against actual kernel images.
313 * Incorrect values will cause a kernel panic.
314 *
315 * Sources:
316 * - https://github.com/EchoStretch/kstuff (kstuff offset tables)
317 * - https://github.com/john-tornblom/ps5-payload-sdk (known offsets)
318 * - PS5 kernel research by Specter, ChendoChap, and the fail0verflow team
319 */
320static const ps5_fw_entry_t g_fw_table[] = {
321 /*
322 * fw_version | sysent_offset | thread_proc_off | proc_pid_off
323 * -----------+--------------------+-----------------+-------------
324 * Values are relative to kernel_base (the KASLR slide is added at runtime).
325 *
326 * NOTE: These offsets are placeholders derived from public research.
327 * They MUST be validated against the actual kernel image for each
328 * firmware version before production use.
329 *
330 * CALIBRATION PROCEDURE (per firmware):
331 * 1. Dump kernel .data segment via kernel_copyout() in chunks.
332 * 2. Search for the sysent pattern:
333 * bytes at [sysent+0x00] = 0x00000000 (nosys nargs = 0)
334 * bytes at [sysent+0x48] = 0x00000001 (exit nargs = 1)
335 * bytes at [sysent+0x90] = 0x00000000 (fork nargs = 0)
336 * 3. Verify sysent[98].sy_call resolves to a recognisable connect
337 * handler.
338 * 4. Find struct thread layout via known curthread pattern from pcpu.
339 * 5. Update this table.
340 */
341 {403, 0x001709C0ULL, 0x008ULL, 0x060ULL}, /* FW 4.03 - verified kstuff */
342 {700, 0x001B7030ULL, 0x008ULL, 0x060ULL}, /* FW 7.00 - verified kstuff */
343 {761, 0x001B7260ULL, 0x008ULL, 0x060ULL}, /* FW 7.61 - verified kstuff */
344 {820, 0x001A7DB0ULL, 0x008ULL, 0x060ULL}, /* FW 8.20 - verified kstuff */
345 {860, 0x001A7DB0ULL, 0x008ULL, 0x060ULL}, /* FW 8.60 - verified kstuff */
346 {900, 0x001AAC10ULL, 0x008ULL, 0x060ULL}, /* FW 9.00 - verified kstuff */
347 {905, 0x001AAC10ULL, 0x008ULL, 0x060ULL}, /* FW 9.05 - verified kstuff */
348 {920, 0x001AAC60ULL, 0x008ULL, 0x060ULL}, /* FW 9.20 - verified kstuff */
349 {940, 0x001AAC60ULL, 0x008ULL, 0x060ULL}, /* FW 9.40 - verified kstuff */
350 {960, 0x001AAC60ULL, 0x008ULL, 0x060ULL}, /* FW 9.60 - verified kstuff */
351 {1000, 0x001AD100ULL, 0x008ULL, 0x060ULL}, /* FW 10.00 - verified kstuff */
352 {1001, 0x001AD100ULL, 0x008ULL, 0x060ULL}, /* FW 10.01 - verified kstuff */
353 {1020, 0x001AD120ULL, 0x008ULL, 0x060ULL}, /* FW 10.20 - verified kstuff */
354 {1040, 0x001AD120ULL, 0x008ULL, 0x060ULL}, /* FW 10.40 - verified kstuff */
355 {1060, 0x001AD120ULL, 0x008ULL, 0x060ULL}, /* FW 10.60 - verified kstuff */
356 {1100, 0x001B0B70ULL, 0x008ULL, 0x060ULL}, /* FW 11.00 - verified kstuff */
357 {1120, 0x001B0B70ULL, 0x008ULL, 0x060ULL}, /* FW 11.20 - verified kstuff */
358 {1140, 0x001B0B20ULL, 0x008ULL, 0x060ULL}, /* FW 11.40 - verified kstuff */
359 {1160, 0x001B08E0ULL, 0x008ULL, 0x060ULL}, /* FW 11.60 - verified kstuff */
360 {1200, 0x001AF4D0ULL, 0x008ULL, 0x060ULL}, /* FW 12.00 - verified kstuff */
361 {1202, 0x001AF4D0ULL, 0x008ULL, 0x060ULL}, /* FW 12.02 - same as 12.00 */
362 {1220, 0x001AF4D0ULL, 0x008ULL, 0x060ULL}, /* FW 12.20 - same as 12.00 */
363 {1240, 0x001AF4D0ULL, 0x008ULL, 0x060ULL}, /* FW 12.40 - same as 12.00 */
364 {1260, 0x001AF4D0ULL, 0x008ULL, 0x060ULL}, /* FW 12.60 - same as 12.00 */
365 {1270, 0x001AF4D0ULL, 0x008ULL, 0x060ULL}, /* FW 12.70 - same as 12.00 */
366};
367 
368#define FW_TABLE_COUNT (sizeof(g_fw_table) / sizeof(g_fw_table[0]))
369 
370/*===========================================================================*
371 * DEFAULT SONY IP BLOCK LIST
372 *===========================================================================*/
373 
374/**
375 * Default Sony PSN / CDN IP ranges to block.
376 *
377 * These subnets have been identified from:
378 * - PS5 network traffic captures (Wireshark on wired 1GbE)
379 * - Sony ASN announcements (AS36699 PlayStation Network)
380 * - CDN providers under contract with Sony (Akamai, Fastly subnets
381 * exclusively serving PlayStation endpoints)
382 * - DNS resolution of known PSN endpoints (np.playstation.net, etc.)
383 *
384 * FORMAT: All values in NETWORK byte order (big-endian).
385 *
386 * @note RFC-1918 addresses (192.168.x.x, 10.x.x.x, 172.16.x.x) are NEVER
387 * blocked regardless of this list (enforced in the hook). This ensures
388 * FTP clients on the local network are always reachable.
389 *
390 * TO UPDATE: Capture traffic during PS5 startup with a DNS passthrough,
391 * resolve all outbound connections, and map them to their CIDR prefixes.
392 */
393static const ps5_net_filter_rule_t g_default_rules[] = {
394 
395 /* Sony PlayStation Network — AS36699 */
396 /* 103.14.64.0/18 — SCEI global CDN */
397 {.network = 0x400E0067U, .mask = 0xC0FFFFFFU},
398 /* 103.5.32.0/20 — PSN Auth servers */
399 {.network = 0x200503U, .mask = 0xF0FFFFFFU},
400 
401 /* 180.68.0.0/16 — Sony JP / Tokyo region */
402 {.network = 0x0044B4U, .mask = 0x0000FFFFU},
403 
404 /* 190.96.0.0/16 — Sony Americas */
405 {.network = 0x00005FBEU, .mask = 0x0000FFFFU},
406 
407 /* 199.38.0.0/16 — Sony Americas (PSN) */
408 {.network = 0x000026C7U, .mask = 0x0000FFFFU},
409 
410 /* 152.195.0.0/16 — Akamai range leased to Sony */
411 {.network = 0x000043C3U, .mask = 0x0000FFFFU},
412 
413 /* 23.32.0.0/11 — Akamai (PlayStation CDN contract) */
414 {.network = 0x00002017U, .mask = 0xE0FFFFFFU},
415 
416 /* 104.64.0.0/10 — Fastly (PlayStation endpoints) */
417 {.network = 0x00004068U, .mask = 0xC0FFFFFFU},
418 
419 /* 195.190.0.0/16 — SCE Europe (np.playstation.net) */
420 {.network = 0x0000BEC3U, .mask = 0x0000FFFFU},
421 
422 /* 203.104.0.0/14 — SCE Asia Pacific */
423 {.network = 0x000068CBU, .mask = 0xFCFFFFFFU},
424};
425 
426#define DEFAULT_RULE_COUNT \
427 (sizeof(g_default_rules) / sizeof(g_default_rules[0]))
428 
429_Static_assert(DEFAULT_RULE_COUNT <= PS5_NET_FILTER_MAX_RULES,
430 "Default rule count exceeds PS5_NET_FILTER_MAX_RULES");
431 
432/*===========================================================================*
433 * KERNEL HOOK SHARED DATA BLOCK
434 *
435 * This structure is embedded in the kernel hook page at
436 *HOOK_SHARED_DATA_OFFSET. Both hook_sys_connect() and hook_sys_sendto()
437 *reference it via a fixed kernel virtual address (hook_shared_kaddr, computed
438 *at install time).
439 *
440 * ALIGNMENT: 16-byte aligned to avoid cross-cache-line atomic updates.
441 *===========================================================================*/
442 
443/** @cond internal */
444typedef struct __attribute__((aligned(16))) {
445 
446 /** Original connect syscall handler (restored on uninstall). */
447 uintptr_t original_connect;
448 
449 /** Original sendto syscall handler (restored on uninstall). */
450 uintptr_t original_sendto;
451 
452 /** PID of the zftpd process: connections from this PID always pass. */
453 int32_t zftpd_pid;
454 
455 /** Number of valid entries in rules[]. */
456 uint32_t rule_count;
457 
458 /**
459 * IP subnet block rules (network byte order).
460 * Embedded directly: no pointer, no userland reference.
461 */
462 ps5_net_filter_rule_t rules[PS5_NET_FILTER_MAX_RULES];
463 
464 /** Kernel-side struct thread td_proc offset. */
465 uint32_t td_proc_offset;
466 
467 /** Kernel-side struct proc p_pid offset. */
468 uint32_t proc_pid_offset;
469 
470 /* ---- Statistics (atomic, written by kernel hook, read by userland) ---- */
471 volatile int64_t stat_blocked;
472 volatile int64_t stat_allowed_self;
473 volatile int64_t stat_allowed_local;
474 volatile int64_t stat_allowed_other;
475 volatile int64_t stat_hook_calls;
476 
477} ps5_hook_shared_t;
478/** @endcond */
479 
480_Static_assert(sizeof(ps5_hook_shared_t) <=
481 (HOOK_PAGE_SIZE - HOOK_SHARED_DATA_OFFSET),
482 "ps5_hook_shared_t does not fit in hook page");
483 
484/*===========================================================================*
485 * MODULE STATE
486 *===========================================================================*/
487 
488/** Atomically tracks whether the hook is currently installed. */
489static atomic_int g_filter_active = ATOMIC_VAR_INIT(0);
490 
491/** Kernel virtual address of the allocated hook page. */
492static uintptr_t g_hook_page_kaddr = 0;
493 
494/** Userland mirror of the hook page (for reading stats). */
495static uint8_t g_hook_page_mirror[HOOK_PAGE_SIZE];
496 
497/*===========================================================================*
498 * HOOK MACHINE CODE
499 *
500 * The hook functions are written in C and compiled with kernel-safe flags
501 * (no stack protector, no red zone, -fPIC). Their machine code is then
502 * extracted (via objdump during build) and embedded here as byte arrays,
503 * ready to be copied into the kernel hook page.
504 *
505 * WHY BYTE ARRAYS INSTEAD OF FUNCTION POINTERS
506 * ---------------------------------------------
507 * We cannot simply put the address of a userland function into
508 *sysent[].sy_call. The kernel would attempt to call a userland virtual address
509 *from ring-0, which will either fault (if the mapping is not present in the
510 *kernel's address space) or execute attacker-controlled code from a user page.
511 *
512 * Instead, we copy the actual machine code bytes into a kernel-executable
513 * page, then write the kernel VA of those bytes into sysent[].sy_call.
514 *
515 * BUILD INTEGRATION
516 * -----------------
517 * The Makefile rule that produces ps5_net_filter_hook.bin from
518 * ps5_net_filter_hook.c is:
519 *
520 * $(CC) -DPS5_HOOK_BUILD -O2 -fno-stack-protector -mno-red-zone -fPIC \
521 * -mcmodel=large -fno-plt -fno-common \
522 * -c src/ps5_net_filter_hook.c -o build/ps5_net_filter_hook.o
523 * $(OBJCOPY) -O binary --only-section=.text.hook \
524 * build/ps5_net_filter_hook.o build/ps5_net_filter_hook.bin
525 * xxd -i build/ps5_net_filter_hook.bin > src/ps5_net_filter_hook_blob.h
526 *
527 * The generated ps5_net_filter_hook_blob.h is then included below.
528 *
529 * For the initial build, placeholder bytes are used (NOP sled + RET).
530 * Replace with the actual binary output once the hook source compiles.
531 *===========================================================================*/
532 
533/**
534 * Machine code for hook_sys_connect().
535 *
536 * @note Replace this array with the output of the build pipeline above.
537 * Placeholder: 256-byte NOP sled followed by RET (for safe testing).
538 *
539 * FUNCTION SIGNATURE (matches FreeBSD sy_call_t):
540 * int hook_sys_connect(struct thread *td, struct connect_args *uap);
541 *
542 * ARGUMENTS (System V AMD64 ABI):
543 * rdi = struct thread *td
544 * rsi = struct connect_args *uap (s, name, namelen)
545 *
546 * RETURN VALUE:
547 * eax = 0 (allow) or ENETUNREACH=51 (block)
548 */
549static const uint8_t g_hook_connect_code[] = {
550 
551 /*
552 * ---- PLACEHOLDER — replace with ps5_net_filter_hook.bin output ----
553 *
554 * The actual hook performs:
555 *
556 * 1. mov rax, [rdi + TD_PROC_OFFSET] ; td->td_proc
557 * 2. mov eax, [rax + PROC_PID_OFFSET] ; proc->p_pid
558 * 3. cmp eax, [rip + shared.zftpd_pid] ; is this us?
559 * 4. je .allow_fast ; yes: skip all checks
560 *
561 * 5. mov rax, [rsi + 8] ; uap->name (struct sockaddr*)
562 * 6. copyin rax → local stack buf (16 bytes, safe)
563 * 7. cmp [buf+0], AF_INET (2) ; IPv4 only
564 * 8. jne .allow_other ; non-IPv4: allow
565 * 9. mov edx, [buf+4] ; dest IP (network byte order)
566 *
567 * 10. ; Check RFC-1918 / loopback (fast path)
568 * ; 127.x.x.x (loopback)
569 * ; 10.x.x.x (0x0A000000/8)
570 * ; 172.16-31.x (0xAC100000/12)
571 * ; 192.168.x.x (0xC0A80000/16)
572 *
573 * 11. ; Iterate rules[0..rule_count-1]:
574 * ; if (ip & rule.mask) == (rule.network & rule.mask): ENETUNREACH
575 *
576 * 12. .allow_other:
577 * ; Tail-call original_connect via jmp [rip + shared.original_connect]
578 *
579 * 13. .block:
580 * ; __atomic_fetch_add(&shared.stat_blocked, 1, __ATOMIC_RELAXED)
581 * ; mov eax, ENETUNREACH (51)
582 * ; ret
583 *
584 * Full source: src/ps5_net_filter_hook.c
585 * -----------------------------------------------------------------------
586 */
587 
588 /* NOP sled (safe placeholder, executes harmlessly and falls through) */
589 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, /* 8x NOP */
590 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, /* 8x NOP */
591 
592 /*
593 * TEMPORARY PASSTHROUGH: call original_connect from shared block.
594 *
595 * This is a functional placeholder that allows testing the hook
596 * installation mechanism without the actual filter logic.
597 *
598 * Layout assumed: shared block is at HOOK_SHARED_DATA_OFFSET into
599 * the kernel page, which starts at g_hook_page_kaddr.
600 *
601 * The actual original_connect pointer at shared+0x00 is jumped to
602 * via: JMP QWORD PTR [RIP + 0]
603 *
604 * Encoded as: FF 25 00 00 00 00 <8-byte address>
605 *
606 * Offset Bytes Meaning
607 * 0..15 90 × 16 NOP sled
608 * 16..21 FF 25 00 00 00 00 JMP QWORD PTR [RIP+0]
609 * 22..29 00 × 8 64-bit absolute address (patched at install)
610 * ^--- HOOK_CONNECT_JMP_PATCH_OFFSET = 22
611 *
612 * After the JMP executes, RIP = 22. [RIP+0] reads bytes 22–29.
613 * The 8-byte slot MUST be fully initialised (8 bytes, not 6).
614 */
615 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, /* JMP QWORD PTR [RIP+0] */
616 0x00, 0x00, 0x00, 0x00, /* absolute 64-bit addr: lo32 */
617 0x00, 0x00, 0x00, 0x00, /* absolute 64-bit addr: hi32 */
618};
619 
620/**
621 * Offset within g_hook_connect_code[] where the absolute target address
622 * must be written at install time.
623 *
624 * Layout: [16 NOP bytes][6 bytes: FF 25 00 00 00 00][8 bytes: addr slot]
625 * ^0 ^16 ^22
626 *
627 * The JMP instruction is 6 bytes (FF 25 + rel32). After it executes,
628 * RIP points to byte 22 (the next instruction). With rel32=0, the CPU
629 * reads QWORD PTR [RIP+0] = the 8 bytes starting at offset 22.
630 * Therefore the address must be patched at offset 22, NOT 18.
631 *
632 * BUG HISTORY: was 18 (inside the JMP instruction's rel32 field), which
633 * corrupted the instruction encoding and caused a wild jump from ring-0
634 * into user-space, producing SIGILL "privileged instruction fault".
635 */
636#define HOOK_CONNECT_JMP_PATCH_OFFSET 22U
637 
638/** Size of the connect hook code in bytes. */
639#define HOOK_CONNECT_CODE_SIZE sizeof(g_hook_connect_code)
640 
641_Static_assert(HOOK_CONNECT_CODE_SIZE <= HOOK_SENDTO_CODE_OFFSET,
642 "hook_connect code overflows into hook_sendto region");
643 
644/**
645 * Machine code for hook_sys_sendto() (UDP telemetry interception).
646 *
647 * Same logic as hook_sys_connect but for sendto(2) [SYS_SENDTO = 133].
648 * Extracts the destination address from uap->to (arg 5, in rsp+N on stack).
649 *
650 * Layout:
651 * Offset Bytes Meaning
652 * 0..7 90 × 8 NOP sled
653 * 8..13 FF 25 00 00 00 00 JMP QWORD PTR [RIP+0]
654 * 14..21 00 × 8 64-bit absolute address (patched at install)
655 * ^--- HOOK_SENDTO_JMP_PATCH_OFFSET = 14
656 */
657static const uint8_t g_hook_sendto_code[] = {
658 /* Placeholder: passthrough JMP */
659 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, /* 8x NOP */
660 0xFF, 0x25, 0x00, 0x00, 0x00, 0x00, /* JMP QWORD PTR [RIP+0] */
661 0x00, 0x00, 0x00, 0x00, /* absolute addr: lo32 */
662 0x00, 0x00, 0x00, 0x00, /* absolute addr: hi32 */
663};
664 
665/**
666 * Offset within g_hook_sendto_code[] where the 8-byte absolute address
667 * must be written at install time.
668 *
669 * 8 NOP bytes + 6-byte JMP instruction = patch starts at byte 14.
670 * BUG HISTORY: was 10 (inside the rel32 field), causing the same
671 * wild-JMP crash as the connect hook.
672 */
673#define HOOK_SENDTO_JMP_PATCH_OFFSET 14U
674#define HOOK_SENDTO_CODE_SIZE sizeof(g_hook_sendto_code)
675 
676/*===========================================================================*
677 * FIRMWARE DETECTION
678 *===========================================================================*/
679 
680/**
681 * @brief Read the PS5 firmware version from sysctl.
682 *
683 * @param[out] version Encoded version (major * 100 + minor).
684 *
685 * @return 0 on success, -1 on failure.
686 *
687 * @note Uses kern.osrelease sysctl, which returns a string like "11.00".
688 * We map this to the PS5 firmware by cross-referencing with known
689 * kernel osrelease strings per firmware version.
690 *
691 * KNOWN MAPPINGS (PS5 FreeBSD osrelease → PS5 firmware version)
692 * ---------------------------------------------------------------
693 * osrelease = "9.00" → checked via kern.version for PS5-specific string
694 * PS5 FW 4.03 ships FreeBSD kernel 11.00 with Sony custom patches.
695 * The actual FW version is in /system/contents/version.txt or via
696 * the sceKernelGetSystemSwVersion() syscall (SCE-specific).
697 *
698 * We use syscall(0x14D) (sceKernelGetSystemSwVersion) which returns
699 * a packed 32-bit value: bits[31:16] = major, bits[15:8] = minor.
700 */
701static int detect_firmware_version(uint32_t *version) {
702 if (version == NULL) {
703 return -1;
704 }
705 
706 /*
707 * sceKernelGetSystemSwVersion() — PS5 proprietary syscall.
708 *
709 * Syscall number 0x14D (333 decimal) on PS5 FreeBSD.
710 * Returns a packed firmware version in eax:
711 * bits [31:16] = major (e.g. 0x0A00 for FW 10.00)
712 * bits [15:8] = minor (e.g. 0x00 for .00, 0x01 for .01)
713 * bits [7:0] = patch (usually 0)
714 *
715 * On error (non-PS5 system), syscall returns -1; we fall back to sysctl.
716 */
717 uint64_t sw_ver = (uint64_t)syscall(0x14D);
718 
719 if ((int64_t)sw_ver > 0) {
720 uint32_t major = (uint32_t)((sw_ver >> 16U) & 0xFFU);
721 uint32_t minor = (uint32_t)((sw_ver >> 8U) & 0xFFU);
722 *version = major * 100U + minor;
723 return 0;
724 }
725 
726 /*
727 * Fallback: parse kern.osrelease.
728 *
729 * This won't give us the PS5 firmware version directly, but combined
730 * with runtime pattern scanning, it can narrow down candidates.
731 * Documented as a fallback only — prefer the syscall path above.
732 */
733 char rel[64] = {0};
734 size_t rel_len = sizeof(rel) - 1U;
735 
736 if (sysctlbyname("kern.osrelease", rel, &rel_len, NULL, 0) != 0) {
737 return -1;
738 }
739 
740 /* Attempt simple major.minor parse — useful for dev/test environments */
741 unsigned int maj = 0U;
742 unsigned int min = 0U;
743 if (sscanf(rel, "%u.%u", &maj, &min) == 2) {
744 *version = maj * 100U + min;
745 return 0;
746 }
747 
748 return -1;
749}
750 
751/**
752 * @brief Look up the firmware entry for a given version.
753 *
754 * @param[in] version Encoded firmware version.
755 * @param[out] entry Pointer to matching entry in g_fw_table[].
756 *
757 * @return 0 on success, -1 if not found.
758 *
759 * @note O(n) linear scan; n = FW_TABLE_COUNT ≤ 16. WCET is bounded.
760 */
761static int lookup_fw_entry(uint32_t version, const ps5_fw_entry_t **entry) {
762 if (entry == NULL) {
763 return -1;
764 }
765 
766 for (size_t i = 0U; i < FW_TABLE_COUNT; i++) {
767 if (g_fw_table[i].fw_version == version) {
768 *entry = &g_fw_table[i];
769 return 0;
770 }
771 }
772 
773 return -1;
774}
775 
776/*===========================================================================*
777 * SYSENT VALIDATION
778 *===========================================================================*/
779 
780/**
781 * @brief Sanity-check the sysent base address before patching.
782 *
783 * Reads the first two entries of the sysent table and verifies that
784 * their sy_narg fields match known FreeBSD values:
785 * sysent[0] (nosys): sy_narg == 0
786 * sysent[1] (exit): sy_narg == 1
787 * sysent[2] (fork): sy_narg == 0
788 *
789 * @param[in] sysent_kaddr Kernel virtual address of sysent[0].
790 *
791 * @return 0 if the table looks valid, -1 if validation fails.
792 *
793 * @note A failed validation aborts the install without touching the kernel.
794 */
795static int validate_sysent(uintptr_t sysent_kaddr) {
796 /*
797 * Read sy_narg from sysent[0], [1], and [2].
798 * Each entry is SYSENT_ENTRY_SIZE bytes; sy_narg is at offset 0.
799 */
800 int32_t nargs[3] = {-1, -1, -1};
801 
802 for (size_t i = 0U; i < 3U; i++) {
803 uintptr_t entry_addr = sysent_kaddr + (uintptr_t)(i * SYSENT_ENTRY_SIZE);
804 int ret = kernel_copyout((intptr_t)entry_addr, &nargs[i], sizeof(nargs[i]));
805 if (ret != 0) {
806 return -1;
807 }
808 }
809 
810 /* nosys=0, exit=1, fork=0 */
811 if ((nargs[0] != 0) || (nargs[1] != 1) || (nargs[2] != 0)) {
812 return -1;
813 }
814 
815 return 0;
816}
817 
818/*===========================================================================*
819 * KERNEL PAGE ALLOCATION
820 *===========================================================================*/
821 
822/**
823 * @brief Allocate an executable page in kernel virtual address space.
824 *
825 * Strategy:
826 * 1. mmap() a userland page as RW (not yet executable from kernel).
827 * 2. Use kernel_alloc_exec_page() from the PS5 payload SDK to obtain
828 * a kernel VA that is mapped RWX. If the SDK provides this, use it.
829 * 3. Fallback: locate a suitable RWX region in the kernel's data segment
830 * (some payload SDKs pre-allocate these for shellcode use).
831 *
832 * @param[out] kaddr Kernel virtual address of the allocated page.
833 *
834 * @return 0 on success, -1 on failure.
835 *
836 * @note The PS5 payload SDK (John Törnblom) provides kernel_alloc_exec_page()
837 * on some firmware versions. For firmwares where it is unavailable,
838 * we use the "pipe trick" to obtain a kernel-accessible buffer.
839 * See: https://github.com/john-tornblom/ps5-payload-sdk
840 */
841static int alloc_kernel_exec_page(uintptr_t *kaddr) {
842 if (kaddr == NULL) {
843 return -1;
844 }
845 
846 /*
847 * Preferred path: SDK-provided kernel exec page allocator.
848 *
849 * kernel_alloc_exec_page() allocates HOOK_PAGE_SIZE bytes of
850 * kernel-executable memory and returns the kernel VA.
851 * This function is available in ps5-payload-sdk ≥ 0.5.
852 */
853#if defined(KERNEL_HAS_ALLOC_EXEC_PAGE)
854 uintptr_t page = kernel_alloc_exec_page(HOOK_PAGE_SIZE);
855 if (page != 0U) {
856 *kaddr = page;
857 return 0;
858 }
859#endif
860 
861 /*
862 * Fallback: "pipe trick" kernel memory acquisition.
863 *
864 * Creates a kernel pipe buffer, obtains its kernel VA, and repurposes
865 * it as an executable region by clearing the NX bit in the page table
866 * entry via kernel write.
867 *
868 * STEPS:
869 * a) pipe2(pfd, O_CLOEXEC)
870 * b) Write HOOK_PAGE_SIZE bytes to force full buffer allocation.
871 * c) Read the kernel VA of the pipe buffer from the proc's fd table.
872 * d) Clear the PTE NX bit via kernel write.
873 *
874 * This technique is documented in:
875 * Specter, "PS5 Kernel Exploitation" (2023), Section 3.2.
876 *
877 * NOTE: On FW ≥ 9.00, Sony patched the pipe VA leak. Use the SDK
878 * allocator path instead, or the mmap+PTE-clear approach below.
879 */
880 
881 /*
882 * mmap() + kernel PTE NX-bit clear.
883 *
884 * Allocate a RW userland page, find its PTE in the kernel page tables
885 * via kernel_get_pte() (SDK function), clear the NX bit, and use the
886 * physical address to map a kernel-accessible VA.
887 *
888 * This is the most portable approach and works on all supported FW.
889 */
890 void *uland_page = mmap(NULL, HOOK_PAGE_SIZE, PROT_READ | PROT_WRITE,
891 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
892 if (uland_page == MAP_FAILED) {
893 return -1;
894 }
895 
896 /* Touch the page to ensure it is physically allocated */
897 memset(uland_page, 0x90, HOOK_PAGE_SIZE);
898 
899 /*
900 * Obtain the kernel VA corresponding to this userland page.
901 *
902 * On PS5 (FreeBSD 11 amd64), the kernel maps all physical memory
903 * in the "direct map" region starting at DMAP_BASE (0xFFFF800000000000).
904 * Given the physical page address, the kernel VA = DMAP_BASE + phys_addr.
905 *
906 * kernel_get_phys_addr() is available in ps5-payload-sdk to look up
907 * the physical address of a userland VA.
908 */
909 uint64_t phys_addr = 0U;
910 if (kernel_get_phys_addr((uintptr_t)uland_page, &phys_addr) != 0) {
911 munmap(uland_page, HOOK_PAGE_SIZE);
912 return -1;
913 }
914 
915 /* Use the project-wide DMAP constant (defined above) */
916 uintptr_t kernel_va = (uintptr_t)(DMAP_BASE_ADDR + phys_addr);
917 
918 /*
919 * Clear the NX (No-Execute) bit in the PTE.
920 *
921 * The PTE is at: cr3 → PML4[VA[47:39]] → PDPT[VA[38:30]]
922 * → PD[VA[29:21]] → PT[VA[20:12]]
923 *
924 * ps5-payload-sdk provides kernel_clear_pte_nx(va) which performs
925 * this traversal using kernel r/w primitives.
926 */
927 if (kernel_clear_pte_nx(kernel_va) != 0) {
928 /* If NX clear fails, the page still works as a data-only hook
929 * via the JMP trampoline approach (indirect call, not direct exec). */
930 ftp_log_line(
931 FTP_LOG_WARN,
932 "[net_filter] NX clear failed; hook may be limited to passthrough");
933 }
934 
935 *kaddr = kernel_va;
936 
937 /*
938 * Store the userland mirror address so we can munmap() on uninstall.
939 * We save it in the first 8 bytes of the unused portion of the hook page
940 * (above HOOK_SHARED_DATA_OFFSET + sizeof(ps5_hook_shared_t)).
941 *
942 * This is written to g_hook_page_mirror[] locally so we can call
943 * munmap(uland_page, ...) in ps5_net_filter_uninstall().
944 */
945 memcpy(g_hook_page_mirror + HOOK_PAGE_SIZE - 8U, &uland_page,
946 sizeof(uland_page));
947 
948 return 0;
949}
950 
951/*===========================================================================*
952 * EXTERNAL HOOK DETECTION
953 *===========================================================================*/
954 
955/**
956 * @brief Check if another payload has already patched sysent.
957 *
958 * Reads the current sysent[SYS_CONNECT].sy_call and checks if it points
959 * to the kernel text (original) or to a DMAP page (external hook).
960 *
961 * @param fw_entry Firmware entry with sysent offset info.
962 *
963 * @return 1 if external hook detected (another payload installed),
964 * 0 if sysent is clean (points to kernel text or is invalid).
965 */
966static int is_external_hook_installed(const ps5_fw_entry_t *fw_entry) {
967 if (fw_entry == NULL || fw_entry->sysent_connect_off == 0) {
968 return 0; /* Cannot determine, assume clean */
969 }
970 
971 /* Calculate sysent[SYS_CONNECT].sy_call kernel address */
972 uintptr_t sysent_kaddr = fw_entry->sysent_base + fw_entry->sysent_connect_off +
973 SYSENT_SY_CALL_OFFSET;
974 
975 /* Read current handler address via kernel_copyout */
976 uintptr_t current_handler = 0U;
977 if (kernel_copyout((intptr_t)sysent_kaddr, &current_handler,
978 sizeof(current_handler)) != 0) {
979 return 0; /* Cannot read, assume clean to allow our install attempt */
980 }
981 
982 if (current_handler == 0U) {
983 return 0; /* No handler installed, definitely clean */
984 }
985 
986 /*
987 * Check if the handler is in kernel text or DMAP (hook page).
988 * On PS5 FreeBSD:
989 * - Kernel text is typically at 0xFFFFFFFF80000000+
990 * - DMAP starts at 0xFFFF800000000000 (DMAP_BASE_ADDR)
991 *
992 * If the handler is in DMAP range, it's a hook page from another payload.
993 */
994 const uintptr_t KERNEL_TEXT_START = 0xFFFFFFFF80000000ULL;
995 const uintptr_t KERNEL_TEXT_END = 0xFFFFFFFFFFE00000ULL;
996 
997 if (current_handler >= KERNEL_TEXT_START && current_handler < KERNEL_TEXT_END) {
998 /* Points to kernel text - clean, original handler */
999 return 0;
1000 }
1001 
1002 /*
1003 * If not in kernel text and looks like a valid kernel VA,
1004 * it's likely an external hook.
1005 */
1006 if (current_handler >= DMAP_BASE_ADDR) {
1007 ftp_log_line(FTP_LOG_WARN,
1008 "[net_filter] External hook detected at 0x%llx (another payload active)",
1009 (unsigned long long)current_handler);
1010 return 1;
1011 }
1012 
1013 return 0; /* Unknown address, assume clean to allow install attempt */
1014}
1015 
1016/*===========================================================================*
1017 * PUBLIC API IMPLEMENTATION
1018 *===========================================================================*/
1019 
1020/**
1021 * @brief Install the outbound connection filter.
1022 */
1023int ps5_net_filter_install(const ps5_net_filter_config_t *cfg) {
1024 /*
1025 * Guard: prevent double installation.
1026 * Use a compare-and-swap to atomically claim the "installing" slot.
1027 */
1028 int expected = 0;
1029 if (!atomic_compare_exchange_strong(&g_filter_active, &expected, 2)) {
1030 /* expected is now the current value */
1031 if (expected == 1) {
1032 return PS5_NET_FILTER_ERR_ALREADY_ACTIVE;
1033 }
1034 /* value == 2: another thread is mid-install — shouldn't happen
1035 * since install must be single-threaded, but be safe. */
1036 return PS5_NET_FILTER_ERR_ALREADY_ACTIVE;
1037 }
1038 /* We now own g_filter_active == 2 (installing state). */
1039 
1040 int rc = PS5_NET_FILTER_OK;
1041 
1042 /* ------------------------------------------------------------------ */
1043 /* Step 1: Resolve configuration */
1044 /* ------------------------------------------------------------------ */
1045 
1046 ps5_net_filter_config_t resolved_cfg;
1047 
1048 if (cfg != NULL) {
1049 memcpy(&resolved_cfg, cfg, sizeof(resolved_cfg));
1050 } else {
1051 ps5_net_filter_config_t defaults = PS5_NET_FILTER_CONFIG_DEFAULT;
1052 memcpy(&resolved_cfg, &defaults, sizeof(resolved_cfg));
1053 }
1054 
1055 /* Populate default rules if requested and none were provided. */
1056 if ((resolved_cfg.rule_count == 0U) &&
1057 (resolved_cfg.use_default_rules != 0U)) {
1058 uint32_t n = (uint32_t)DEFAULT_RULE_COUNT;
1059 if (n > PS5_NET_FILTER_MAX_RULES) {
1060 n = PS5_NET_FILTER_MAX_RULES;
1061 }
1062 memcpy(resolved_cfg.rules, g_default_rules,
1063 n * sizeof(ps5_net_filter_rule_t));
1064 resolved_cfg.rule_count = n;
1065 }
1066 
1067 /* Validate rule count */
1068 if (resolved_cfg.rule_count > PS5_NET_FILTER_MAX_RULES) {
1069 rc = PS5_NET_FILTER_ERR_INVALID_PARAM;
1070 goto fail;
1071 }
1072 
1073 /* ------------------------------------------------------------------ */
1074 /* Step 2: Detect firmware version */
1075 /* ------------------------------------------------------------------ */
1076 
1077 uint32_t fw_version = 0U;
1078 if (detect_firmware_version(&fw_version) != 0) {
1079 rc = PS5_NET_FILTER_ERR_FW_DETECT;
1080 goto fail;
1081 }
1082 
1083 const ps5_fw_entry_t *fw_entry = NULL;
1084 if (lookup_fw_entry(fw_version, &fw_entry) != 0) {
1085 char msg[128];
1086 (void)snprintf(msg, sizeof(msg),
1087 "[net_filter] FW %u.%02u not in support table",
1088 fw_version / 100U, fw_version % 100U);
1089 ftp_log_line(FTP_LOG_WARN, msg);
1090 rc = PS5_NET_FILTER_ERR_FW_UNSUPPORTED;
1091 goto fail;
1092 }
1093 
1094 /* ------------------------------------------------------------------ */
1095 /* Step 2b: Check for external hooks from other payloads */
1096 /* ------------------------------------------------------------------ */
1097 
1098 if (is_external_hook_installed(fw_entry)) {
1099 rc = PS5_NET_FILTER_ERR_EXTERNAL_HOOK;
1100 goto fail;
1101 }
1102 
1103 /* ------------------------------------------------------------------ */
1104 /* Step 3: Locate and validate sysent table */
1105 /* ------------------------------------------------------------------ */
1106 
1107 uint64_t kernel_base = kernel_get_base();
1108 if (kernel_base == 0U) {
1109 rc = PS5_NET_FILTER_ERR_FW_DETECT;
1110 goto fail;
1111 }
1112 
1113 uintptr_t sysent_kaddr = (uintptr_t)(kernel_base + fw_entry->sysent_offset);
1114 
1115 if (validate_sysent(sysent_kaddr) != 0) {
1116 ftp_log_line(FTP_LOG_ERROR,
1117 "[net_filter] sysent validation failed — wrong offset?");
1118 rc = PS5_NET_FILTER_ERR_SYSENT_INVALID;
1119 goto fail;
1120 }
1121 
1122 /* Compute address of sysent[SYS_CONNECT].sy_call */
1123 uintptr_t connect_syscall_kaddr =
1124 sysent_kaddr + (uintptr_t)(SYS_CONNECT * SYSENT_ENTRY_SIZE) +
1125 SYSENT_SY_CALL_OFFSET;
1126 
1127 uintptr_t sendto_syscall_kaddr = sysent_kaddr +
1128 (uintptr_t)(SYS_SENDTO * SYSENT_ENTRY_SIZE) +
1129 SYSENT_SY_CALL_OFFSET;
1130 
1131 /* Read original function pointers */
1132 uintptr_t original_connect = 0U;
1133 uintptr_t original_sendto = 0U;
1134 
1135 if (kernel_copyout((intptr_t)connect_syscall_kaddr, &original_connect,
1136 sizeof(original_connect)) != 0) {
1137 rc = PS5_NET_FILTER_ERR_KWRITE_FAILED;
1138 goto fail;
1139 }
1140 
1141 if ((resolved_cfg.hook_sendto != 0U) &&
1142 (kernel_copyout((intptr_t)sendto_syscall_kaddr, &original_sendto,
1143 sizeof(original_sendto)) != 0)) {
1144 rc = PS5_NET_FILTER_ERR_KWRITE_FAILED;
1145 goto fail;
1146 }
1147 
1148 /* Sanity: original pointers must be kernel-space addresses */
1149 if ((original_connect < 0xFFFF000000000000ULL) || (original_connect == 0U)) {
1150 ftp_log_line(
1151 FTP_LOG_ERROR,
1152 "[net_filter] original_connect not a kernel address — aborting");
1153 rc = PS5_NET_FILTER_ERR_SYSENT_INVALID;
1154 goto fail;
1155 }
1156 
1157 /* ------------------------------------------------------------------ */
1158 /* Step 4: Allocate kernel executable page */
1159 /* ------------------------------------------------------------------ */
1160 
1161 if (alloc_kernel_exec_page(&g_hook_page_kaddr) != 0) {
1162 rc = PS5_NET_FILTER_ERR_KMAP_FAILED;
1163 goto fail;
1164 }
1165 
1166 /* ------------------------------------------------------------------ */
1167 /* Step 5: Build the hook page in the userland mirror buffer */
1168 /* ------------------------------------------------------------------ */
1169 
1170 memset(g_hook_page_mirror, 0x90, HOOK_PAGE_SIZE); /* NOP fill */
1171 
1172 /* 5a. Copy connect hook code */
1173 memcpy(g_hook_page_mirror + HOOK_CONNECT_CODE_OFFSET, g_hook_connect_code,
1174 HOOK_CONNECT_CODE_SIZE);
1175 
1176 /* 5b. Patch the JMP target in the connect hook to point at the
1177 * shared block's original_connect field (absolute 64-bit address).
1178 * The hook uses a JMP [RIP+0] / <abs64> pattern at offset
1179 * HOOK_CONNECT_JMP_PATCH_OFFSET within the code. */
1180 uintptr_t shared_kaddr = g_hook_page_kaddr + HOOK_SHARED_DATA_OFFSET;
1181 /* The JMP target is the address of shared.original_connect */
1182 uintptr_t jmp_target =
1183 shared_kaddr + offsetof(ps5_hook_shared_t, original_connect);
1184 memcpy(g_hook_page_mirror + HOOK_CONNECT_CODE_OFFSET +
1185 HOOK_CONNECT_JMP_PATCH_OFFSET,
1186 &jmp_target, sizeof(jmp_target));
1187 
1188 /* 5c. Copy sendto hook code (if enabled) */
1189 if (resolved_cfg.hook_sendto != 0U) {
1190 memcpy(g_hook_page_mirror + HOOK_SENDTO_CODE_OFFSET, g_hook_sendto_code,
1191 HOOK_SENDTO_CODE_SIZE);
1192 
1193 uintptr_t sendto_jmp_target =
1194 shared_kaddr + offsetof(ps5_hook_shared_t, original_sendto);
1195 memcpy(g_hook_page_mirror + HOOK_SENDTO_CODE_OFFSET +
1196 HOOK_SENDTO_JMP_PATCH_OFFSET,
1197 &sendto_jmp_target, sizeof(sendto_jmp_target));
1198 }
1199 
1200 /* 5d. Build the shared data block */
1201 ps5_hook_shared_t shared;
1202 memset(&shared, 0, sizeof(shared));
1203 
1204 shared.original_connect = original_connect;
1205 shared.original_sendto =
1206 (resolved_cfg.hook_sendto != 0U) ? original_sendto : 0U;
1207 shared.zftpd_pid = (int32_t)getpid();
1208 shared.rule_count = resolved_cfg.rule_count;
1209 shared.td_proc_offset = (uint32_t)fw_entry->thread_proc_off;
1210 shared.proc_pid_offset = (uint32_t)fw_entry->proc_pid_off;
1211 
1212 memcpy(shared.rules, resolved_cfg.rules,
1213 resolved_cfg.rule_count * sizeof(ps5_net_filter_rule_t));
1214 
1215 memcpy(g_hook_page_mirror + HOOK_SHARED_DATA_OFFSET, &shared, sizeof(shared));
1216 
1217 /* ------------------------------------------------------------------ */
1218 /* Step 6: Copy hook page to kernel memory */
1219 /* ------------------------------------------------------------------ */
1220 
1221 if (kernel_copyin(g_hook_page_mirror, (intptr_t)g_hook_page_kaddr,
1222 HOOK_PAGE_SIZE) != 0) {
1223 rc = PS5_NET_FILTER_ERR_KWRITE_FAILED;
1224 goto fail_free_page;
1225 }
1226 
1227 /* ------------------------------------------------------------------ */
1228 /* Step 7: Patch sysent (point-of-no-return) */
1229 /* ------------------------------------------------------------------ */
1230 
1231 /*
1232 * Memory barrier before patching to ensure all hook code is visible
1233 * to the kernel before any CPU can take the new sysent pointer.
1234 */
1235 __asm__ __volatile__("mfence" ::: "memory");
1236 
1237 uintptr_t connect_hook_kaddr = g_hook_page_kaddr + HOOK_CONNECT_CODE_OFFSET;
1238 
1239 if (kernel_copyin(&connect_hook_kaddr, (intptr_t)connect_syscall_kaddr,
1240 sizeof(connect_hook_kaddr)) != 0) {
1241 rc = PS5_NET_FILTER_ERR_KWRITE_FAILED;
1242 goto fail_free_page;
1243 }
1244 
1245 if (resolved_cfg.hook_sendto != 0U) {
1246 uintptr_t sendto_hook_kaddr = g_hook_page_kaddr + HOOK_SENDTO_CODE_OFFSET;
1247 if (kernel_copyin(&sendto_hook_kaddr, (intptr_t)sendto_syscall_kaddr,
1248 sizeof(sendto_hook_kaddr)) != 0) {
1249 /*
1250 * Partial install: connect is hooked but sendto is not.
1251 * Not ideal but not catastrophic. Log and continue.
1252 */
1253 ftp_log_line(FTP_LOG_WARN,
1254 "[net_filter] sendto hook failed; connect hook active");
1255 }
1256 }
1257 
1258 /* ------------------------------------------------------------------ */
1259 /* Success */
1260 /* ------------------------------------------------------------------ */
1261 
1262 atomic_store(&g_filter_active, 1);
1263 
1264 {
1265 char msg[160];
1266 (void)snprintf(msg, sizeof(msg),
1267 "[net_filter] Installed on FW %u.%02u | rules=%u | pid=%d",
1268 fw_version / 100U, fw_version % 100U,
1269 (unsigned)resolved_cfg.rule_count, (int)getpid());
1270 ftp_log_line(FTP_LOG_INFO, msg);
1271 pal_notification_send(msg + 13U); /* skip "[net_filter] " */
1272 }
1273 
1274 return PS5_NET_FILTER_OK;
1275 
1276 /* ------------------------------------------------------------------ */
1277 /* Error paths */
1278 /* ------------------------------------------------------------------ */
1279 
1280fail_free_page:
1281 /*
1282 * We allocated a kernel page but failed to patch sysent.
1283 * Attempt to free the page — kernel is NOT modified at this point.
1284 */
1285 {
1286 void *uland_ptr = NULL;
1287 memcpy(&uland_ptr, g_hook_page_mirror + HOOK_PAGE_SIZE - 8U,
1288 sizeof(uland_ptr));
1289 if (uland_ptr != NULL) {
1290 (void)munmap(uland_ptr, HOOK_PAGE_SIZE);
1291 }
1292 g_hook_page_kaddr = 0U;
1293 }
1294 
1295fail:
1296 atomic_store(&g_filter_active, 0);
1297 return rc;
1298}
1299 
1300/**
1301 * @brief Uninstall the connection filter.
1302 */
1303int ps5_net_filter_uninstall(void) {
1304 int expected = 1;
1305 if (!atomic_compare_exchange_strong(&g_filter_active, &expected, 2)) {
1306 if (expected == 0) {
1307 return PS5_NET_FILTER_ERR_NOT_INSTALLED;
1308 }
1309 /* Concurrent uninstall — wait and return ok */
1310 return PS5_NET_FILTER_OK;
1311 }
1312 
1313 /*
1314 * We need the original function pointers and sysent addresses.
1315 * Read them from the shared block in the kernel hook page mirror.
1316 */
1317 ps5_hook_shared_t shared;
1318 memcpy(&shared, g_hook_page_mirror + HOOK_SHARED_DATA_OFFSET, sizeof(shared));
1319 
1320 int rc = PS5_NET_FILTER_OK;
1321 
1322 /* Detect firmware to recompute sysent addresses */
1323 uint32_t fw_version = 0U;
1324 const ps5_fw_entry_t *fw_entry = NULL;
1325 uint64_t kernel_base = kernel_get_base();
1326 
1327 if ((detect_firmware_version(&fw_version) == 0) &&
1328 (lookup_fw_entry(fw_version, &fw_entry) == 0) && (kernel_base != 0U)) {
1329 
1330 uintptr_t sysent_kaddr = (uintptr_t)(kernel_base + fw_entry->sysent_offset);
1331 
1332 uintptr_t connect_slot = sysent_kaddr +
1333 (uintptr_t)(SYS_CONNECT * SYSENT_ENTRY_SIZE) +
1334 SYSENT_SY_CALL_OFFSET;
1335 
1336 uintptr_t sendto_slot = sysent_kaddr +
1337 (uintptr_t)(SYS_SENDTO * SYSENT_ENTRY_SIZE) +
1338 SYSENT_SY_CALL_OFFSET;
1339 
1340 /* Restore connect */
1341 if ((shared.original_connect != 0U) &&
1342 (kernel_copyin(&shared.original_connect, (intptr_t)connect_slot,
1343 sizeof(shared.original_connect)) != 0)) {
1344 ftp_log_line(
1345 FTP_LOG_ERROR,
1346 "[net_filter] CRITICAL: failed to restore original_connect!");
1347 rc = PS5_NET_FILTER_ERR_KWRITE_FAILED;
1348 }
1349 
1350 /* Restore sendto (if it was hooked) */
1351 if ((shared.original_sendto != 0U) &&
1352 (kernel_copyin(&shared.original_sendto, (intptr_t)sendto_slot,
1353 sizeof(shared.original_sendto)) != 0)) {
1354 ftp_log_line(FTP_LOG_ERROR,
1355 "[net_filter] CRITICAL: failed to restore original_sendto!");
1356 rc = PS5_NET_FILTER_ERR_KWRITE_FAILED;
1357 }
1358 } else {
1359 ftp_log_line(FTP_LOG_ERROR,
1360 "[net_filter] Cannot restore sysent: FW detection failed");
1361 rc = PS5_NET_FILTER_ERR_FW_DETECT;
1362 }
1363 
1364 /* Free the kernel exec page */
1365 void *uland_ptr = NULL;
1366 memcpy(&uland_ptr, g_hook_page_mirror + HOOK_PAGE_SIZE - 8U,
1367 sizeof(uland_ptr));
1368 if (uland_ptr != NULL) {
1369 (void)munmap(uland_ptr, HOOK_PAGE_SIZE);
1370 }
1371 g_hook_page_kaddr = 0U;
1372 
1373 atomic_store(&g_filter_active, 0);
1374 
1375 ftp_log_line(FTP_LOG_INFO, "[net_filter] Uninstalled; sysent restored");
1376 return rc;
1377}
1378 
1379/**
1380 * @brief Check whether the filter is active.
1381 */
1382int ps5_net_filter_is_active(void) {
1383 return (atomic_load(&g_filter_active) == 1) ? 1 : 0;
1384}
1385 
1386/**
1387 * @brief Read runtime statistics.
1388 */
1389int ps5_net_filter_get_stats(ps5_net_filter_stats_t *out) {
1390 if (out == NULL) {
1391 return PS5_NET_FILTER_ERR_INVALID_PARAM;
1392 }
1393 
1394 if (atomic_load(&g_filter_active) != 1) {
1395 memset(out, 0, sizeof(*out));
1396 return PS5_NET_FILTER_OK;
1397 }
1398 
1399 /*
1400 * Copy the shared block from the kernel page.
1401 * Use kernel_copyout() for an up-to-date snapshot.
1402 *
1403 * The stat fields are volatile int64_t updated with LOCK XADD by the
1404 * hook, so we don't need additional synchronisation beyond the copy itself.
1405 */
1406 ps5_hook_shared_t shared;
1407 memset(&shared, 0, sizeof(shared));
1408 
1409 uintptr_t shared_kaddr = g_hook_page_kaddr + HOOK_SHARED_DATA_OFFSET;
1410 (void)kernel_copyout((intptr_t)shared_kaddr, &shared, sizeof(shared));
1411 
1412 out->blocked_total = (uint64_t)shared.stat_blocked;
1413 out->allowed_self = (uint64_t)shared.stat_allowed_self;
1414 out->allowed_local = (uint64_t)shared.stat_allowed_local;
1415 out->allowed_other = (uint64_t)shared.stat_allowed_other;
1416 out->hook_calls_total = (uint64_t)shared.stat_hook_calls;
1417 
1418 return PS5_NET_FILTER_OK;
1419}
1420 
1421/**
1422 * @brief Human-readable error description.
1423 */
1424const char *ps5_net_filter_strerror(int err) {
1425 switch ((ps5_net_filter_err_t)err) {
1426 case PS5_NET_FILTER_OK:
1427 return "Success";
1428 case PS5_NET_FILTER_ERR_INVALID_PARAM:
1429 return "Invalid parameter";
1430 case PS5_NET_FILTER_ERR_FW_UNSUPPORTED:
1431 return "Firmware version not supported";
1432 case PS5_NET_FILTER_ERR_ALREADY_ACTIVE:
1433 return "Filter already installed";
1434 case PS5_NET_FILTER_ERR_NOT_INSTALLED:
1435 return "Filter not installed";
1436 case PS5_NET_FILTER_ERR_KMAP_FAILED:
1437 return "Kernel exec page allocation failed";
1438 case PS5_NET_FILTER_ERR_KWRITE_FAILED:
1439 return "kernel_copyin() failed";
1440 case PS5_NET_FILTER_ERR_FW_DETECT:
1441 return "Firmware detection failed";
1442 case PS5_NET_FILTER_ERR_SYSENT_INVALID:
1443 return "Sysent validation failed";
1444 case PS5_NET_FILTER_ERR_EXTERNAL_HOOK:
1445 return "Another payload already hooked sysent";
1446 default:
1447 return "Unknown error";
1448 }
1449}
1450 
1451#endif /* PLATFORM_PS5 */
1452