This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
# PS5 payload loader contract audit
|
||||
|
||||
Audit date: 2026-07-17. Scope: pinned public PS5 Payload SDK v0.41 at
|
||||
`d2e2e585740362976a39fdd5ccf390f199a7bc37`, inspected offline. No PS5 was
|
||||
contacted and no ELF was built, transferred, or executed.
|
||||
|
||||
## Available primary evidence
|
||||
|
||||
The SDK README names three external loader families: `ps5-payload-dev/elfldr`,
|
||||
`cryonumb/elfloader` through `ps5-jar-loader`, and
|
||||
`shahrilnet/remote_lua_loader`. None of those caller repositories is present in
|
||||
the pinned local evidence set. A `git grep` of every other local primary-source
|
||||
checkout found no `payload_args_t` or `payloadout` caller implementation. The
|
||||
only available contract is therefore the SDK callee side:
|
||||
|
||||
- `include/ps5/payload.h` defines the six-field `payload_args_t`;
|
||||
- `crt/crt.c` defines `int _start(payload_args_t *args)` and labels it as the
|
||||
entry invoked by the ELF loader;
|
||||
- `crt1.o` disassembly shows the first argument consumed from `RDI` and
|
||||
`payloadout` read at offset `0x28`;
|
||||
- the SDK linker script defines image, BSS, constructor, destructor, and
|
||||
dynamic-section boundaries.
|
||||
|
||||
These facts prove the callee layout. They do not prove how a particular loader
|
||||
constructs the argument, stack, return address, or post-return state.
|
||||
|
||||
## Argument layout
|
||||
|
||||
For the audited x86-64 target the public fields are six eight-byte values in
|
||||
this order:
|
||||
|
||||
| Offset | Field | Callee use |
|
||||
|---:|---|---|
|
||||
| `0x00` | `sys_dynlib_dlsym` | SDK syscall/symbol bootstrap |
|
||||
| `0x08` | `rwpipe` | existing kernel read/write transport descriptors |
|
||||
| `0x10` | `rwpair` | existing kernel read/write transport descriptors |
|
||||
| `0x18` | `kpipe_addr` | existing kernel pipe address |
|
||||
| `0x20` | `kdata_base_addr` | existing kernel data-base address |
|
||||
| `0x28` | `payloadout` | result location written by the SDK CRT |
|
||||
|
||||
The 48-byte layout is `SAFE` as a source and disassembly fact. The lifetime,
|
||||
writability, ownership, nullability, and post-return consumption of every
|
||||
pointer are `UNPROVEN` because the caller is absent.
|
||||
|
||||
## Entry ABI and stack
|
||||
|
||||
The compiled SDK `_start` has a normal x86-64 function prologue, consumes the
|
||||
argument from `RDI`, makes further calls without dynamically realigning `RSP`,
|
||||
and ends one path with `ret`. This establishes what the callee expects, not what
|
||||
the loader guarantees. In particular, the following remain `UNPROVEN`:
|
||||
|
||||
- the exact stack alignment at entry;
|
||||
- whether a valid return address is present;
|
||||
- which registers or floating-point state the loader expects preserved;
|
||||
- whether the loader calls the entry as an ordinary function or transfers
|
||||
control by another mechanism;
|
||||
- whether the memory behind `payload_args_t` remains valid until return.
|
||||
|
||||
## BSS and image initialization
|
||||
|
||||
The SDK linker script provides `__bss_start` and `__bss_end`. The stock `_start`
|
||||
explicitly zeros that range byte-by-byte before accessing its own BSS state.
|
||||
The loop itself is freestanding and has no import, so a comparable loop is
|
||||
technically possible. It is not evidence that the loader maps every segment
|
||||
with the permissions, alignment, relocation state, or zero-fill behavior a new
|
||||
artifact would require. Those caller/mapper properties are `UNPROVEN`.
|
||||
|
||||
## Return, exit, crash, and cleanup
|
||||
|
||||
The stock CRT does not define one universal return rule. Its
|
||||
`payload_terminate()` first tries to detect a hijacked process with
|
||||
`kernel_dynlib_dlsym(-1, 0x2001, "sceKernelDlsym")`. In that branch it returns
|
||||
from `_start`. Otherwise it resolves and invokes `exit`; if that fails it
|
||||
executes a trap. This heuristic itself depends on the syscall/kernel/rtld stack
|
||||
that Phase 0.5 forbids.
|
||||
|
||||
Consequently, a custom `_start` that simply executes `ret` cannot be claimed
|
||||
safe from the SDK callee alone. No local primary evidence proves:
|
||||
|
||||
- who consumes the integer return value;
|
||||
- whether return resumes a hijacked process or exits a dedicated process;
|
||||
- which mappings, descriptors, credentials, or signal state are restored;
|
||||
- what happens after an invalid argument, fault, trap, or partial BSS clear;
|
||||
- whether any cleanup exists after a crash.
|
||||
|
||||
All five are `UNPROVEN` and are build blockers.
|
||||
|
||||
## Loader state versus artifact-caused state
|
||||
|
||||
The argument fields show that kernel read/write descriptors and kernel
|
||||
addresses already exist when the SDK entry is called. That is a fact about the
|
||||
incoming state, not proof of how the loader created it. The exact loader may
|
||||
have changed credentials, syscall bounds, mappings, modules, or other process
|
||||
state before entry; the available callee source cannot distinguish those
|
||||
possibilities.
|
||||
|
||||
The stock SDK artifact then independently causes additional writes through
|
||||
`__patch_init`; those writes are attributable to `crt1.o` and are `UNSAFE`.
|
||||
No Phase-0.5 candidate artifact exists, so it caused no new state changes.
|
||||
|
||||
## Contract decision
|
||||
|
||||
The public callee contract is insufficient for a safe-return artifact.
|
||||
Decision: **BLOCKED**. The minimal missing evidence is the exact loader used for
|
||||
firmware 9.60 at a pinned public commit, including its entry call site,
|
||||
argument ownership, return path, pre-entry changes, cleanup, and crash path.
|
||||
|
||||
Machine evidence: `manifests/runtime/phase-0.5-startup-audit.json`.
|
||||
|
||||
## Phase 0.6 exact-loader addendum
|
||||
|
||||
The Phase-0.5 conclusion above is preserved as the evidence available at that
|
||||
time. Phase 0.6 identified the installed loader exactly through the read-only
|
||||
Payload Manager inventory and a byte-for-byte public release match:
|
||||
|
||||
- Payload Manager v0.3.1, commit
|
||||
`cfbc70f30f419b09bf2b52283f7409e2d3117ee1`, SHA-256
|
||||
`518740adbacccb9094fadb07dd424c53ee290f38306449ccc9d6957fdf813c0b`;
|
||||
- `ps5-payload-dev/elfldr` v0.23, commit
|
||||
`699e8bcff03e91e8d6ca6eba281af25c5a58d8c2`, SHA-256
|
||||
`092d16ee0ede0c494947efd38d1a17bbd7cc4b022d3858ea898833c188c703e8`.
|
||||
|
||||
The proposed `itsPLK/ps5-elfldr` v0.23.2 candidate is not installed: its
|
||||
release hash is
|
||||
`2c28f847c859d3126b0f089ee23b72b84a733548da222ceafd91ae2f3df8f933`.
|
||||
The exact exploit/autoloader that originally starts the first-stage loader
|
||||
remains unidentified.
|
||||
|
||||
### Proven entry setup
|
||||
|
||||
The exact v0.23 source creates a new process using
|
||||
`rfork_thread(RFPROC | RFCFDG | RFMEM)`. The child enables tracing and executes
|
||||
`/system/vsh/app/NPXS40112/eboot.bin` (`SceSpZeroConf`). The parent stops at an
|
||||
INT3 placed 58 bytes after the mapped eboot entry, restores that byte, maps the
|
||||
payload, and then sets:
|
||||
|
||||
| Register/state | Exact loader operation | Status |
|
||||
|---|---|---|
|
||||
| `RSP` | writes observed `RIP` at `RSP - 8`, then subtracts 8 | `SAFE` source fact |
|
||||
| `RIP` | payload ELF entry | `SAFE` source fact |
|
||||
| `RDI` | loader-created `payload_args_t` page | `SAFE` source fact |
|
||||
| breakpoint byte | original byte restored before injection | `RESTORED_BY_LOADER` |
|
||||
| breakpoint page permission | changed to RWX and not restored | `PAYLOAD_PROCESS_LOCAL` |
|
||||
|
||||
This is a synthetic function-call frame. The public source does not establish
|
||||
the meaning of the observed trap `RIP`, the exact original instruction
|
||||
boundary, or the validity of resuming there after payload return. Therefore
|
||||
the continuation is still `UNPROVEN`.
|
||||
|
||||
### Argument ownership and lifetime
|
||||
|
||||
`elfldr_payload_args()` maps one page into the child, creates two IPv6 sockets
|
||||
and a pipe, overlaps the sockets through the SDK kernel helper, and populates
|
||||
the six public fields. On successful detach the loader does not unmap that
|
||||
page or close those child descriptors. They are process-local, but cleanup
|
||||
depends on the child terminating. That termination and reaping path is not
|
||||
bounded or proven.
|
||||
|
||||
### Return, exit, crash, and hang
|
||||
|
||||
The stock SDK v0.41 `_start` may return, resolve and call `exit`, or trap,
|
||||
depending on `payload_terminate()`. Which branch applies to this injected
|
||||
SceSpZeroConf child is not proven. Separately, elfldr:
|
||||
|
||||
- has no payload wall-clock deadline after `PT_DETACH`;
|
||||
- has unbounded single-step loops in both `pt_call()` and `pt_syscall()`;
|
||||
- ignores `SIGCHLD` in the service and documents no post-detach reaping
|
||||
contract;
|
||||
- kills the child on some preparation errors, but does not supervise normal
|
||||
runtime, return, crash, or hang.
|
||||
|
||||
These are `UNBOUNDED_OR_UNKNOWN`, not objections to the mere presence of
|
||||
temporary kernel/process writes. They block the requested controlled
|
||||
two-second lifecycle.
|
||||
|
||||
### Existing loader state versus artifact-caused state
|
||||
|
||||
The first-stage loader enables QA flag bits and temporarily changes
|
||||
credentials before the candidate payload exists. Those are existing-loader
|
||||
effects. For each ptrace operation the service temporarily changes its authid
|
||||
and normally restores it. The child receives process-local credentials,
|
||||
mappings, sockets, pipes, executable permissions, and SDK `__patch_init`
|
||||
changes. The artifact would cause SDK startup only after entry; it would not
|
||||
cause the loader bootstrap.
|
||||
|
||||
The separation does not make the chain ready: UID restoration is absent,
|
||||
authid restoration has an unrecovered failure path, and child lifetime is
|
||||
unbounded. Full evidence is in
|
||||
`manifests/runtime/phase-0.6-loader-runtime-audit.json`.
|
||||
|
||||
## Phase 0.7 hardened loader contract
|
||||
|
||||
Phase 0.7 does not depend on safely returning from `main`. The lifecycle
|
||||
probe calls `_exit` after exactly one notification, so the unresolved SDK
|
||||
`payload_terminate` return/exit/trap branch is outside the reachable
|
||||
application path.
|
||||
|
||||
The hardened elfldr preserves the public `payload_args_t *` entry convention
|
||||
and adds a controlled versioned transport header with exact artifact ID,
|
||||
firmware 9.60, size, SHA-256, 2000 ms timeout, and no-retry fields. Both sender
|
||||
and receiver validate the metadata; the receiver hashes the actual received
|
||||
bytes and rejects the permanent denylist.
|
||||
|
||||
The loader's process mutations are exact and bounded:
|
||||
|
||||
- the initial QA flag change is an existing volatile bootstrap effect;
|
||||
- ptrace service auth-ID and all child UID/jail/root/caps/auth fields are
|
||||
backed up, restored, and checked;
|
||||
- breakpoint byte and page protection are restored;
|
||||
- single-step loops have independent time and step limits;
|
||||
- every incomplete launch path terminates and reaps the child;
|
||||
- the detached success path is replaced with a two-second supervised wait,
|
||||
bounded termination escalation, and reap;
|
||||
- an auth restoration failure permanently disables further ptrace work and
|
||||
stops the service.
|
||||
|
||||
This is sufficient for the offline
|
||||
`READY_FOR_HARDENED_RUNTIME_DEPLOYMENT` decision. Loader behavior on firmware
|
||||
9.60 remains `UNPROVEN` until separately authorized installation and execution
|
||||
produce on-device evidence.
|
||||
Reference in New Issue
Block a user