10 KiB
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.hdefines the six-fieldpayload_args_t;crt/crt.cdefinesint _start(payload_args_t *args)and labels it as the entry invoked by the ELF loader;crt1.odisassembly shows the first argument consumed fromRDIandpayloadoutread at offset0x28;- 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_tremains 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-256518740adbacccb9094fadb07dd424c53ee290f38306449ccc9d6957fdf813c0b; ps5-payload-dev/elfldrv0.23, commit699e8bcff03e91e8d6ca6eba281af25c5a58d8c2, SHA-256092d16ee0ede0c494947efd38d1a17bbd7cc4b022d3858ea898833c188c703e8.
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()andpt_syscall(); - ignores
SIGCHLDin 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.