Fix out of bounds read in formatString() on a trailing bare '%' - #13
Merged
Conversation
The switch consumed the character after '%' with `switch (*(++src))`. When the '%' was the last character before the terminator, the read yielded '\0', which fell through to `default:` and did `src++`, advancing the source pointer past the terminator. The enclosing while loop then read out of bounds and kept copying until it happened to land on a zero byte. Advance the pointer explicitly and break out of the loop when the '%' has no command character after it. The other cases only run once a real command character has matched, so the single guard covers them. Reachable from application code: LogThat passes a user-supplied message string to formatString(), and a stray trailing '%' in an operator-facing log message is realistic input. Adds a table-driven self test to the AS6 example project, gated by the formatTest BOOL alongside the existing hexTest. Each format is copied into a scratch buffer padded with 'X' past the terminator, so a walk off the end drags the filler into the destination and fails the compare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- ANSIC.lby was left at 1.1.0 while CHANGELOG declared 1.1.1. The previous release bumped both together, and without it Automation Studio cannot tell the fixed library from the broken one. - The two truncation cases passed against the pre-fix code as well: both filled the destination and exited the loop before the '%' was ever examined, so neither exercised the guard. Added the case that does - destSize 8 with "abcdef%" reaches the '%' branch with only the reserved null byte left, and yields "abcdefX"/7 pre-fix versus "abcdef"/6 after. Reworded the comments to say what each case actually covers, and routed them through the 'X'-padded scratch buffer rather than a bare literal so an over-read has known filler to land on. - Guarded the unbounded strcpy into the scratch buffer, so a format added to the table later cannot silently overrun it. - Noted on the new break that it exits the while loop, since it sits three lines above a switch full of breaks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The too-long-format guard incremented formatTestFail without recording the format in formatTestFirstFail, so that one failure mode - a test authoring error rather than a library bug - would show a count with no input to look at. - Rejecting only at sizeof(Scratch) still admitted a format that leaves zero filler after the terminator, which is what the over-read detection relies on. Reject at sizeof(Scratch) - 8 so there is always filler to land on. - The destSize 4 case reused whatever Scratch held from the case above it. Refill it explicitly, like the other two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…g coverage
The self tests could be compiled but not run: the example project has no
comms channel, so the trigger BOOLs could only be set from a debugger
attached by hand. Enable the OPC UA server on the Intel configuration and
publish both suites' trigger and result variables in OpcUaMap.uad, so a
test run is a write and three reads from any OPC UA client.
The endpoint is unencrypted and anonymous by design - this project exists
to compile the library and run its tests, and requiring credentials would
put a password in the repository. The README says plainly that this
configuration must not be copied to a real machine.
formatString coverage had real gaps, none of which the trailing-'%' fix
touched but all of which the fix's own test file was the natural place to
close:
- %r and %f were not exercised at all. Compared against what brsftoa()
itself produces, since the rendering is the runtime's business.
- Only the TRUE half of %b was covered.
- A null entry in the string argument array was untested; it must be
skipped without pulling a later argument into its place.
- Running past the end of an argument array was untested.
- Truncation was only tested where the destination filled before a
format character was reached, never part way THROUGH a substitution.
- A destination size of 0 must write nothing at all, not even the
terminator, since there is no byte to put it in.
The size-dependent cases move into their own table rather than growing
the list of hand-written assertions, and set up their arguments
explicitly instead of inheriting whatever the case table left behind.
Verified on ARsim (AR 6.7.6): 26/26 with the fix, 21/26 without it, the
five failures being the four trailing-'%' cases plus the truncation case
that reaches the '%' branch with only the reserved byte left. The
pre-existing hex suite is 38/38 and unaffected either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review raised a blocker that turned out to be a false positive, and
two findings that were real. Settled the blocker by experiment rather
than by argument, since the harness now makes that cheap:
- <Module Name="Default"> DID publish the variables. Deploying with an
emptied OpcUaMap.uad makes the same node read BadNodeIdUnknown, so
the map is load bearing and was working. Switched to the AS
canonical <Default> spelling anyway - it is what Automation Studio
writes, so leaving it would produce a spurious whole file diff the
first time anyone opens the configuration in the IDE.
- The DefaultRolePermissions group index WAS wrong. Role [0] is a
no-op, so writes were succeeding only because an unconfigured
namespace is unrestricted. Confirmed by deploying Role [1] with
PermissionWrite 0 and watching the write come back
BadUserAccessDenied. Corrected to Role [1], which means the
permissions are now actually enforced rather than incidental.
- Restored the UTF-8 BOM stripped from Package.pkg, and gave the three
new files BOMs and CRLF endings to match every sibling.
The size case loop could not detect the overrun it exists to police: it
zeroed the destination and compared with strcmp, which stops at the
first null. It now fills with 'Z' and asserts that every byte from
DestSize to the end of the buffer is untouched. Both loops also bound
the scratch buffer with a terminator, so running the suite against a
library that does walk off the end stops at the buffer instead of
leaving it.
Coverage added where the review found untested paths:
- %b under truncation. It is the only branch whose lengths are hard
coded rather than computed, so it is the likeliest place for a
length bug, and nothing exercised it.
- %i, which is an alias for %d and had no test at all.
- The %s argument array running out, which reaches exhaustion through
a different condition than %d does.
- A large magnitude real, since 1.5 leaves the library's 16 byte
scratch buffer almost entirely untouched.
The README now names the port, says the encrypted policies remain
offered alongside the anonymous one, and states that the result
variables are anonymously writable too, not just the triggers.
Verified on ARsim (AR 6.7.6): 30/30 with the fix, 25/30 without it, the
same five trailing-'%' detectors. Hex suite 38/38 and unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sclaiborne
force-pushed
the
claude/trusting-noyce-35718d
branch
from
August 25, 2026 18:16
e004954 to
f14f403
Compare
CI rejected the build outright: error 9346, OpcUaCs 6.5.0 is not
installed on the AS6 runner. The runner's inventory is not knowable from
a workstation, so a throwaway diagnostic step listed it - the runner has
6.0.0 and 6.6.1, this workstation has 6.0.0, 6.5.0 and 6.7.0. That step
is not part of this commit; it existed only long enough to answer the
question.
6.0.0 is the only version present on both, and it does not work: the
generated NodeSet fails to load at runtime with BadNodeIdUnknown while
adding a reference for the first published variable, so nothing is
published at all. It builds, which is exactly why it needed running
rather than compiling. Pinned 6.6.1 instead, which the runner has.
That leaves a real gap, recorded here rather than papered over: 6.6.1 is
not installed on this workstation, so the pinned version has been
verified to BUILD by CI but not to publish at runtime. Runtime
publishing was verified on 6.5.0 and 6.7.0, and 6.6.1 sits between them.
Aligning the runner and the workstation on one version would close this
properly.
Review follow-ups in the same commit:
- The large-real case could not detect what it was added to detect.
Expected was 16 bytes, the same budget as the library's own
temp_string, so a value needing more would smash the test's stack at
brsftoa() before formatString() was ever called - undefined
behaviour rather than a reported failure. Expected is now 64 bytes
and the case asserts strlen(Expected) < 16 explicitly, which is the
condition the library actually depends on.
- OpcUaMap.uad now matches what Automation Studio emits: the
<?AutomationStudio FileVersion="6.0"?> instruction and the <Aliases>
block, and no xmlns attributes AS does not write. The earlier
reasoning for omitting these - that the AS templates lack them - was
wrong; the templates are not what AS writes on save.
- The three reals blocks now bound their scratch buffer with a
terminator, like both loops already did.
Verified on ARsim (AR 6.7.6, OpcUaCs 6.7.0): 30/30, NodeSet loaded, and
the hex suite 38/38.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sclaiborne
force-pushed
the
claude/trusting-noyce-35718d
branch
from
August 25, 2026 18:20
f14f403 to
a73276c
Compare
Declaring OpcUaCs in the .apj makes the technology package project wide, so every configuration needs a *.uacfg, not just the one that uses it. CI caught this: Intel built, ARM failed with error 5198. ARM gets a config with the server disabled, which is the Automation Studio default and leaves the configuration behaving exactly as before. The self tests are driven on Intel, which is the configuration ARsim simulates; there is no reason to open a server on the configuration meant for real hardware, and good reason not to. Verified locally as far as this workstation allows: with the file in place the ARM build gets past error 5198 and fails later on a hardware support file that AR 6.7.6 does not carry for the X20CP0410, which is a local install gap rather than a project problem. CI builds ARM against AR 6.6.2 and is the real check. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pinned version is the one the build runner has, which is not the one whose runtime behaviour was confirmed, and 6.0.0 fails in a way that looks like success until you look for the variables. Someone debugging a missing node should not have to reconstruct that from commit messages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
formatString()consumed the character after%withswitch (*(++src)). When the%was the last character before the terminator, that read yielded'\0', fell through todefault:, and didsrc++— advancing the source pointer past the null terminator. The enclosingwhile ((*src != '\0') && (destSize > 0))then read out of bounds and kept copying until it happened to land on a zero byte.Reproduce:
formatString(dest, sizeof(dest), "value: %", &args)with any non-NULLpArguments.This is reachable from application code — LogThat passes a user-supplied message string to
formatString()(LogInternalWrite.c), and a stray trailing%in an operator-facing log message is realistic input.Fix
Advance the pointer explicitly and break out of the loop when the
%has no command character after it:The
breakexits the enclosingwhile, so the function falls through to*dst = '\0'and returns the length written so far."value: %"now yields"value: "— the stray%is dropped, consistent with howdefault:already discards unknown specifiers.The other
casebodies dosrc++only after*srcmatched a real (non-NUL) command character, so the single guard before the switch covers all of them. No other pointer-advance site in the library has this pattern.Regression test
Follows the existing
hexTestshape in the AS6 example project — a table-driven self test gated by a BOOL, with pass/fail counters and the first failing input.formatTest,formatTestPass,formatTestFail,formatTestFirstFailinVariables.varrunFormatTest()inMain.c, called fromProgramCyclicon a rising edgeEach case's format is copied into a 64-byte scratch buffer pre-filled with
'X', so the bytes after the terminator are junk. Under the old code,"value: %"dragged that filler into the destination; the expected-output compare catches it.Cases cover the four trailing-
%variants ("value: %","%","trail %d%","%%%"), ordinary%d/%s/%bsubstitution,%%, an unknown specifier, the three NULL-pointer inputs, and two truncation cases where a trailing%lands with no room left.Verification
No host C compiler is available on the dev machine (only "Automation Studio Code" is installed, no
BR.AS.Build.exe), so the self test has not been run on hardware yet. Expected values were validated by portingformatStringline-for-line to a throwaway model and running the table against it — all ten table cases and both truncation cases match. CI builds Intel and ARM on the AS6 runner for this PR.🤖 Generated with Claude Code