Tips for payloads
Everyday recipes for building payloads. Each is a self-contained pattern the SDK’s payload API supports, so a payload can pull it in without extra scaffolding.
Print to the kernel log
Every payload should print at least one line so the console log records the run:
using System.Runtime.InteropServices;
internal static unsafe partial class Program
{
[LibraryImport("libScePosix", EntryPoint = "__prospero_klog")]
private static partial void Klog(byte* message);
[UnmanagedCallersOnly(EntryPoint = "__managed__Main")]
public static int Main(void* args)
{
fixed (byte* p = "my-payload: hello\n"u8) Klog(p);
return 0;
}
}
__prospero_klog is defined by the CRT and resolved at link time — no DT_NEEDED needed, no
runtime resolve. Prefix every line with the payload’s name so its output is greppable in the log.
Build and send a payload
A payload is a single .elf a loader maps into a running process. Build one, then send it:
pwsh build/build-app.ps1 -ProjectPath samples/prospero-payload-unjail/SampleApp.csproj -Payload -Output Folder
dotnet run --project tools/SharpProspero.Bindings.Generator -- payload --send \
--host 192.168.1.10 \
--file samples/prospero-payload-unjail/out/SampleApp.elf
Add -DiagnosticBreadcrumbs to include the CRT’s bring-up log checkpoints in the build. See
Sending a payload.
Run as a daemon
Never return from __managed__Main when the payload has to persist. Return terminates the
hijacked thread, so a daemon that returns after a request stops serving after one client.
[UnmanagedCallersOnly(EntryPoint = "__managed__Main")]
public static int Main(void* args)
{
int listener = PayloadNetwork.Listen(9069);
while (true)
{
int client = PayloadNetwork.Accept(listener);
if (client < 0) continue;
HandleClient(client);
PayloadNetwork.Close(client);
}
}
A daemon that binds to a fixed port allows only one instance in the host process. Handle bind failure by exiting; the user can send a fresh payload after restarting the host.
Read the loader-supplied arguments
The loader hands the payload a PayloadArgs* at start-up. Check it first:
PayloadArgs* pargs = PayloadEntryPoint.Args;
if (pargs == null)
{
fixed (byte* p = "my-payload: no args\n"u8) Klog(p);
return -1;
}
A null pointer means the pipe primitive is unavailable — no kernel access will work, and the payload should exit rather than continue with a partial surface.
Resolve an optional symbol
For a symbol that may or may not be on the running firmware, drive the resolver at run time
instead of a LibraryImport:
nint addr = PayloadDlfcn.Dlopen("libSceOptional.sprx"u8, PayloadDlfcn.RTLD_LAZY);
if (addr == 0) { /* fall back */ }
else
{
void* fn = PayloadDlfcn.Dlsym((void*)addr, "sceOptionalCall"u8);
if (fn == null) { /* fall back */ }
// ... use fn ...
}
A LibraryImport fails the whole payload’s link if the symbol is missing; Dlsym returns null and
lets the payload continue.
Send a user-visible notification
A daemon that finished bring-up successfully should tell the user, so the launcher shows it is alive without opening the device log:
PayloadNotification.SendKernelNotification("my-daemon: ready"u8);
For a longer message with a payload-specific icon, use the user-visible variants of
PayloadNotification.
Iterate quickly
The fastest cycle is:
- Edit the payload’s
Program.cs. - Rebuild:
build-app.ps1 -Payload -Output Folder. - Send the fresh
.elfto the same host and port. - Read the device log for the
sp:*breadcrumbs and the payload’s own output.
For a daemon, restart the host process between sends — see Sending a payload.
Read the process list
For a payload that has to target another process by name or title id:
var io = new PayloadKernelIo(PayloadEntryPoint.Args);
ulong proc = PayloadKernel.FindProcessByTitleId(io, titleIdBytes, titleIdBytes.Length);
if (proc == 0) return -1;
int pid = PayloadKernel.ReadPidFromProc(io, proc);
Walking the whole list on every request is fine — the list is short.
Keep the pipe primitive alive
The pipe primitive is set up once by the CRT at start-up. It stays alive for the whole payload’s
run, so hold a single PayloadKernelIo for the lifetime and reuse it:
var io = new PayloadKernelIo(PayloadEntryPoint.Args); // once
// every request:
ulong value = io.ReadU64(someKaddr);
Constructing a fresh PayloadKernelIo per request is not incorrect, but it re-reads payload_args
every time.
Handle a per-firmware address
Every kernel address in the payload comes from the per-firmware data table baked into the CRT. A payload that has to compute a fresh address at run time reads the firmware version first:
byte fwMajor = /* read the running firmware major */;
if (fwMajor != 0x10)
{
fixed (byte* p = "my-payload: unsupported firmware\n"u8) Klog(p);
return -1;
}
Adding a firmware means adding a row to the CRT’s table — see Working with module offsets.
Add a system library to the project
Every LibraryImport for a symbol not in the default SPRX set requires a <ProsperoSprx> entry:
<ItemGroup>
<ProsperoSprx Include="libSceRandom.sprx" />
</ItemGroup>
The linker emits a matching DT_NEEDED entry; the CRT’s RTLD init loads the module and binds every
GLOB_DAT entry for it. See SPRX declarations for the common overrides.
Reserve the two 1280-byte buffers on the wire
Custom wire formats between an application module and a companion payload should always be
2576 bytes total, with the header at +0x00…+0x0F and the two 1280-byte reserved buffers at
+0x10 and +0x510. That lines up with the unjail daemon’s reader, so a future extension can
share the same TCP wire without reshaping the buffers. See
Promoting a running application for the reference layout.
General tips
- Prefix every log line with the payload’s short name.
- Never call
Environment.ExitorThread.Abortfrom a payload — the CRT’s epilogue is what cleanly terminates the hijacked thread. - Prefer the SDK’s
PayloadNetwork,PayloadFileSystem,PayloadNotification,PayloadRandom,PayloadHardwareInfo,PayloadSysctlwrappers over rawLibraryImportcalls. Every wrapper covers one shape of the underlying surface with a small managed API. - Keep the payload’s own state on the stack when possible. A
stackalloc byte[N]is faster and more predictable than the heap. - For a payload that reads
/dataor/app0, remember the paths are those of the host process — they only resolve if the caller’s sandbox allows it. - Verify the built ELF’s
DT_NEEDEDlist with theelfcommand before sending, to catch a missing<ProsperoSprx>at build time instead of asp:resolver:notfoundat run time.