221 lines
10 KiB
Markdown
221 lines
10 KiB
Markdown
# 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.
|