Skip to content

fix(libc): parse NaN payloads in strtod as measured on a PS5 - #7545

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
ionut27001:fix/libc-strtod-nan-payload
Oct 20, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
ionut27001:fix/libc-strtod-nan-payload

Conversation

@ionut27001

@ionut27001 ionut27001 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What

strtod and the related conversions now parse a NAN(...) sequence the way the console's libSceLibcInternal.sprx does when called from a payload process on a PS5 (firmware 12.70); whether a title's own libc.prx does the same is unverified.

Function Console (libSceLibcInternal.sprx, firmware 12.70) main before this PR
strtod, wcstod the payload is kept under the quiet bit, with the sign payload dropped, sign kept
strtof, wcstof always 0x7fc00000; sign dropped, payload ignored 0x7fc00000, or 0xffc00000 after a minus sign
strtold, wcstold always the positive default NaN (exponent 0x7fff, significand 0xc000000000000000) the same NaN, with the sign kept

atof calls strtod here, so it changes with it; the console's atof was not tried.

How strtod reads the sequence. It matches FreeBSD 11's gdtoa hexnan, and the payload goes under the quiet bit as in its strtod:

  • The sequence is hexadecimal digits with an optional 0x. So nan(10) gives payload 0x10.
  • A blank starts a new 32-bit word. So nan(1 ) gives 0x100000000.
  • Only the first 16 digits are kept, and the payload is cut to 52 bits.
  • A zero payload becomes 1.
  • Any other character gives the default NaN, but the parse still consumes up to the closing parenthesis.
  • An unterminated sequence keeps its payload, but only nan is consumed.

HexNanPayload is a direct translation of hexnan for a 52-bit payload, with one exception. In hexnan, a blank after a payload digit, while a 32-bit word of the payload is still free, starts a blank skip that has no NUL check. When only blanks follow up to the terminating NUL, the console reads past it: nan(1 followed by NUL and ) consumes 8 characters, the ) after the NUL included (p71 below). The result then depends on the memory after the string, so HexNanPayload throws for that case. A blank right after nan( is skipped with a NUL check, and there the console stopped at the NUL (open_blank_nul_paren).

How strtof and strtold read it. They consume a sequence of letters, digits and underscores up to its closing parenthesis, as C17 describes. The value is the fixed NaN in the table, whatever the sequence holds.

Where the end pointer comes from. For NaN subjects the end pointer no longer comes from the host's parser. Other inputs still go through the host as before.

Tests: the old test asserted the inferred behaviour. It is replaced by a table of the 64 NaN inputs that p11, p14, p27 and p33 measured on the console, generated from the logs. All 64 go through strtod and strtof, and through atof, which is checked against the console's strtod results. 50 of them also go through strtold and wcstod, and 18 through wcstof and wcstold. The wide -nan(0x123) case in GuestWideStrings.cpp now expects the payload, and the positive NaN for wcstof and wcstold. Two more checks make sure that nan(1 and nan(12 \t throw, and two pin p71 cases measured through strtod: nan(1 2) gives 0x7ff8000100000002 with 8 characters consumed, and nan( followed by NUL and ) gives 0x7ff8000000000000 with 3, the ) after the NUL not consumed.

Debt: in docs/dev/debt/libc.md, the strtod, strtof, strtold entry said that the sequence was ignored, and that this was inferred and not observed. It now describes what was measured, including the case that throws.

How the console was measured

Setup:

  • The console runs firmware 12.70 and is jailbroken.
  • Probes are ELF payloads built with ps5-payload-dev/sdk v0.44 and clang 18.1.8, started through a payload manager, so they ran in a payload process, not in a title.

Method:

  • p27, p33 and p71 resolved each function from libSceLibcInternal.sprx and called it through the returned pointer. p11 and p14 called the linked strtod and strtof, and their resolve lines show these are the exports of libSceLibcInternal.sprx (same_as_called=yes).
  • used is the number of characters consumed.

Checking the model: the attached scripts/nan_model.py is the model in Python. Run from the unpacked attachment, it checks all 64 inputs against the attached logs and finds no mismatch. Like HexNanPayload, it raises where hexnan's in-loop blank skip would read past the terminating NUL.

Attachments: sources and the full logs are in the attached nan-probes.zip.

Console output (excerpt, lines copied unchanged)
p11: meta fw=0x12700001 pid=122 sdk=v0.44 compiler=Clang 18.1.8 built=Oct 18 2026 01:17:43
p14: meta fw=0x12700001 pid=126 sdk=v0.44 compiler=Clang 18.1.8 built=Oct 18 2026 01:52:17
p11: resolve sym=strtod module=libSceLibcInternal.sprx same_as_called=yes
p11: resolve sym=strtof module=libSceLibcInternal.sprx same_as_called=yes
p14: resolve sym=strtod module=libSceLibcInternal.sprx same_as_called=yes
p14: resolve sym=strtof module=libSceLibcInternal.sprx same_as_called=yes
p27: meta fw=0x12700001 pid=140 sdk=v0.44 compiler=Clang 18.1.8 built=Oct 18 2026 03:21:29
p33: meta fw=0x12700001 pid=146 sdk=v0.44 compiler=Clang 18.1.8 built=Oct 18 2026 03:44:33

p27: resolve sym=strtod module=libSceLibcInternal.sprx exported=yes
p27: resolve sym=strtold module=libSceLibcInternal.sprx exported=yes
p27: resolve sym=wcstod module=libSceLibcInternal.sprx exported=yes

p11: case strto text="nan(1)" strtod=0x7ff8000000000001 used_d=6 errno_d=0 strtof=0x7fc00000 used_f=6 errno_f=0
p11: case strto text="nan(abc)" strtod=0x7ff8000000000abc used_d=8 errno_d=0 strtof=0x7fc00000 used_f=8 errno_f=0
p11: case strto text="-nan" strtod=0xfff8000000000000 used_d=4 errno_d=0 strtof=0x7fc00000 used_f=4 errno_f=0
p14: case strto text="nan(10)" strtod=0x7ff8000000000010 used_d=7 errno_d=0 strtof=0x7fc00000 used_f=7 errno_f=0 mxcsr=0x9ffd
p14: case strto text="nan(xyz)" strtod=0x7ff8000000000000 used_d=8 errno_d=0 strtof=0x7fc00000 used_f=8 errno_f=0 mxcsr=0x9ffd
p14: case strto text="nan(12" strtod=0x7ff8000000000012 used_d=3 errno_d=0 strtof=0x7fc00000 used_f=3 errno_f=0 mxcsr=0x9ffd
p27: case nanparse text="nan(0)" strtod=0x7ff8000000000001 used_d=6 strtof=0x7fc00000 used_f=6 strtold=00000000000000c0ff7f used_l=6 wcstod=0x7ff8000000000001 used_w=6
p27: case nanparse text="nan(10000000000000)" strtod=0x7ff8000000000001 used_d=19 strtof=0x7fc00000 used_f=19 strtold=00000000000000c0ff7f used_l=19 wcstod=0x7ff8000000000001 used_w=19
p27: case nanparse text="nan(fffffffffffff)" strtod=0x7fffffffffffffff used_d=18 strtof=0x7fc00000 used_f=18 strtold=00000000000000c0ff7f used_l=18 wcstod=0x7fffffffffffffff used_w=18
p27: case nanparse text="nan(1x2)" strtod=0x7ff8000000000000 used_d=8 strtof=0x7fc00000 used_f=8 strtold=00000000000000c0ff7f used_l=8 wcstod=0x7ff8000000000000 used_w=8
p27: case nanparse text="nan(-1)" strtod=0x7ff8000000000000 used_d=7 strtof=0x7fc00000 used_f=3 strtold=00000000000000c0ff7f used_l=3 wcstod=0x7ff8000000000000 used_w=7
p27: case nanparse text="nan(1 )" strtod=0x7ff8000100000000 used_d=7 strtof=0x7fc00000 used_f=3 strtold=00000000000000c0ff7f used_l=3 wcstod=0x7ff8000100000000 used_w=7
p27: case nanparse text="-nan(1)" strtod=0xfff8000000000001 used_d=7 strtof=0x7fc00000 used_f=7 strtold=00000000000000c0ff7f used_l=7 wcstod=0xfff8000000000001 used_w=7
p33: case nan2 text="-NAN(5)" strtod=0xfff8000000000005/7 strtof=0x7fc00000/7 strtold=00000000000000c0ff7f/7 wcstod=0xfff8000000000005/7 wcstof=0x7fc00000/7 wcstold=00000000000000c0ff7f/7
p33: case nan2 text="nan(a b)" strtod=0x7ff8000a0000000b/8 strtof=0x7fc00000/3 strtold=00000000000000c0ff7f/3 wcstod=0x7ff8000a0000000b/8 wcstof=0x7fc00000/3 wcstold=00000000000000c0ff7f/3
p33: case nan2 text="nan(12 x" strtod=0x7ff8000000000000/3 strtof=0x7fc00000/3 strtold=00000000000000c0ff7f/3 wcstod=0x7ff8000000000000/3 wcstof=0x7fc00000/3 wcstold=00000000000000c0ff7f/3

p71: meta fw=0x12700001 pid=189 sdk=v0.44 compiler=Clang 18.1.8 built=Oct 18 2026 11:34:23
p71: case strtod who=blank_nul_then_paren bytes=6e616e283120002978797a00 bits=0x7ff8000100000000 end_offset=8
p71: case strtod who=nul_then_paren bytes=6e616e2831002978797a00 bits=0x7ff8000000000001 end_offset=3
p71: case strtod who=blank_nul_digits_paren bytes=6e616e28313220002033342900 bits=0x7ff8001200000034 end_offset=12
p71: case strtod who=open_blank_nul_paren bytes=6e616e2820002900 bits=0x7ff8000000000000 end_offset=3
p71: case strtod who=blank_between_digits bytes=6e616e283120322900 bits=0x7ff8000100000002 end_offset=8

The pNN: prefix names the probe; the rest of each line is copied unchanged.

Overlaps:

Tested

Build: Windows 11 x86-64, full build with WinLibs MinGW-w64 GCC 15.2.0 (posix-seh, ucrt, r7) and -DBUILD_TESTING=ON.

Tests: the branch was rebased onto main 63e4a63. The rebase conflicted only in docs/dev/debt/libc.md, where #6345, now merged, had added two lines right after the strtod entry this PR rewrites; the resolution keeps those two lines and this PR's entry. On the rebased branch, guest_math, guest_wide_strings and the other guest_* tests pass: 496 of 497 pass and guest_video_out_wqhd_detection_no_vulkan is skipped on this machine. In the first parallel run (ctest -j 6), guest_pthread_barrier and guest_pthread_rwlock timed out; the whole second run passed. The current main dc54701 merges with this branch without a conflict.

Old code, new test: on the rebased branch, with Strings.cpp reverted to main and the new tests kept, guest_math and guest_wide_strings abort.

Console: behaviour measured on a PS5 (firmware 12.70), as described above.

Not tested here: Linux (a reviewer reported a Linux run in the comments), and no title.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Fixes: none
  • Unblocks: none
  • GPUs tested: n/a
  • AI-assisted: yes

🤖 Generated with Claude Code

@MarcZX10

Copy link
Copy Markdown
Contributor

I compared HexNanPayload with the cited FreeBSD 11 hexnan.c control flow: optional 0x, 32-bit word grouping, payload truncation, zero-to-one handling, and invalid-character consumption through ). The explicit guard for the measured blank-before-NUL overread is reflected in the added regression cases. I found no mismatch in this conversion path. This is a source review only; I did not rerun the guest tests locally, and the hosted Linux/Windows full-build jobs are skipped on this PR.

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 core/libs/prx/libc/src/Strings.cpp
docs/dev/debt/libc.md
PR 6044 core/libs/tests/GuestMath.cpp
core/libs/tests/GuestWideStrings.cpp
PR 6158 core/libs/tests/GuestMath.cpp
PR 6226 docs/dev/debt/libc.md
PR 6469 docs/dev/debt/libc.md
PR 6819 docs/dev/debt/libc.md
PR 6879 core/libs/prx/libc/src/Strings.cpp
PR 7081 docs/dev/debt/libc.md
PR 7201 core/libs/prx/libc/src/Strings.cpp
PR 7332 core/libs/prx/libc/src/Strings.cpp
core/libs/tests/GuestMath.cpp
docs/dev/debt/libc.md
PR 7473 docs/dev/debt/libc.md
PR 7715 docs/dev/debt/libc.md

@ionut27001

Copy link
Copy Markdown
Contributor Author

Thanks for checking it against hexnan.c. The guest tests ran locally on Windows (see Tested): with the branch on main 9f25d0b, 464 of 465 pass (one skipped on this machine), and guest_math and guest_wide_strings pass again after the rebase onto 5bf2e13.

@MarcZX10

Copy link
Copy Markdown
Contributor

Thanks for the Windows test results after the rebase. The 464/465 local result with one environment skip, plus the passing guest_math and guest_wide_strings tests, is useful confirmation; I have not rerun the Windows suite locally.

@github-actions github-actions Bot added the needs rebase Conflicts with main, rebase needed label Oct 19, 2026
In a payload process on a PS5 (firmware 12.70), the strtod and wcstod
of libSceLibcInternal.sprx keep the payload of a NAN(...) sequence,
read as FreeBSD 11's gdtoa hexnan reads it, under the quiet bit and
with the sign. strtof, wcstof, strtold and wcstold return the positive
default quiet NaN whatever the sign and the sequence, and consume a
sequence of letters, digits and underscores up to its closing
parenthesis. Here every NaN lost its payload, and strtof and strtold
kept the sign.

The model reproduces the 64 NaN inputs that p11, p14, p27 and p33
measured, including blanks and other characters inside the parentheses
and unterminated sequences, and the test now pins them. It also pins
two p71 cases measured through strtod: nan(1 2), and "nan( " followed
by NUL and ")". In hexnan, a blank after a payload digit, while a
32-bit word of the payload is still free, starts a blank skip that has
no NUL check, so when only blanks follow up to the terminating NUL the
console reads past it (p71); that case throws here. A blank right after
"nan(" is skipped with a NUL check, and the console stopped at the NUL
there.

AI-assisted: yes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ionut27001
ionut27001 force-pushed the fix/libc-strtod-nan-payload branch from ba2b7aa to ae662c8 Compare October 19, 2026 06:56
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 19, 2026
@blawesom

Copy link
Copy Markdown
Contributor

Rebase handoff — rebased onto current main cleanly (authorship unchanged).

Verified on Linux (CachyOS, kernel 7.2.9, GCC 16.2.1, Ninja, Release, BUILD_TESTING=ON, AMD RX 9070 XT / RADV): builds; conventions pass; full suite green apart from the four known main regressions (#5444 Mesa, #7584 BDA, #7766, #7784), none from this PR.

To pick it up:

git fetch https://github.com/blawesom/AnyPS5 rebase/7545
git checkout <your branch>
git merge --ff-only FETCH_HEAD

Head is 316974147.

@blawesom

Copy link
Copy Markdown
Contributor

Correction: my earlier push of rebase/7545 was actually still on the previous branch's tree (a rebase of #7514's conflicted state — the Texture.cpp build break was from that, not your PR). Your PR rebased cleanly and the correct branch is pushed: head ae662c873, builds, conventions pass, full suite green apart from the four known main regressions (#5444 Mesa, #7584 BDA, #7766, #7784). Apologies for the noise.

@adriadam10 adriadam10 self-assigned this Oct 19, 2026
@adriadam10
adriadam10 merged commit 6bd02dd into boykopovar:main Oct 20, 2026
20 of 21 checks passed
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the HexNanPayload translation against the cited FreeBSD 11 hexnan.c/strtod.c, the 64-row table against the attached console logs (p11/p14/p27/p33/p71), that the new checks pin payload and end-pointer values main did not produce, and the debt entry; third-party Windows and Linux runs are in the thread; merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants