Skip to content

fix: v2.23.0 — §13.1 generic-config privilege escalation + fault-injection use-after-free - #101

Merged
SoundMatt merged 1 commit into
mainfrom
fix/generic-config-privesc-and-faultinject-uaf
Aug 1, 2026
Merged

SoundMatt merged 1 commit into
mainfrom
fix/generic-config-privesc-and-faultinject-uaf

Conversation

@SoundMatt

Copy link
Copy Markdown
Owner

Two unrelated critical defects, both found by an independent audit of this repository, fixed together because neither is large.

1. Privilege escalation on the generic config block (cpp-RCP-D2)

§13.1 grants an RC Client other than the ROOT_CLIENT write access only to the functional config (EP_FUNC_config) of the endpoints allocated to it; §13.2 frames the counterpart block as the generic part of the endpoint register map, owned by the RC Server.

Ep0::check_write_access collapsed that distinction. It applied one identical owner-based authorization test to both ConfigBlock::Generic and ConfigBlock::Functional — only the lock branch, not the authorization branch, differed by block:

const auto& owner = endpoint_owner_[static_cast<size_t>(target - 1)];
if (!is_root_client(client) && (!owner.has_value() || *owner != client))
    return make_error_code(RegMapErrc::unauthorized_access);   // same test for both blocks
const bool locked = (block == ConfigBlock::Generic) ? ... : ...; // only this differed

So write_generic_config succeeded for any non-root client that merely owned the target endpoint.

That is an escalation rather than a cosmetic conformance gap because of what the generic block holds: hw_pin_indices (which physical pins the endpoint claims), request_queue_size/response_queue_size, and the per-endpoint ep_req_crc_enable/ep_ack_crc_enable/ep_response_crc_enable E2E-CRC toggles added at v2.6.0. A non-root client could repoint an owned endpoint's hardware pin mapping or silently switch off a safety mechanism — data the specification reserves to the RC Server and the root client.

Fix. Authorization now depends on the targeted block: generic-block writes require the root client, functional-block writes accept root-or-owner. block keeps defaulting to ConfigBlock::Generic, which is now the stricter of the two, so a call site that forgets to name a block fails closed. write_functional_config, write_whole_map, check_read_access and the cpp-RCP-N2-01/N2-02 size invariants are untouched.

The existing test certified the bug. tests/test_regmap.cpp's "A non-root client may only write the endpoint config it owns" wrote the generic block as a non-root owner and required success. It is split in two: the owner grant is retested against the functional block where it belongs, and a new case pins the generic block as root-only — exercising write_generic_config and check_write_access directly (including the default-argument path), and asserting the refused write left the config untouched. The generic-block lock test now claims the root client first, so the lock rather than the new authorization check is still what refuses that write.

2. Use-after-free in faultinject::Interceptor::pick_rule (cpp-RCP-D6)

pick_rule returned a Rule* into its own std::vector<Rule> and erased that very element before returning whenever the firing exhausted the rule's count:

Rule* r = &(*it);
++r->fired;
if (r->count > 0 && r->fired >= r->count) {
    it = rules_.erase(it);   // erases the element r points to
}
return r;                    // returns the now-dangling pointer

send() then dereferences it immediately (rule->type, rule->latency). Erasing a trailing element does not reallocate on common libstdc++/libc++ builds, so unsanitized builds read stale-but-mapped memory and appeared to work — which is why the existing count-expiry test never failed.

The same pointer was also a data race: rules_ is guarded only for the duration of pick_rule(), so send() read into the container with no lock held while another thread could be reallocating it via add_rule, emptying it via clear_rules, or expiring rules of its own — in a class that holds a std::mutex precisely because it is meant to be used concurrently.

Fix. pick_rule returns std::optional<Rule>, copied out while the lock is still held and before any erase; send() acts on that value. Rule is four scalars, so the copy is free.

Verification

Full local build and ctest pass, 53/53 suites. Both fixes were mutation-checked.

cpp-RCP-D2. Reverting the authorization to the pre-fix owner-only test (is_root_client(client) || owns) fails the new generic-block case:

Owning an endpoint does not grant a non-root client write access to its generic config
tests/test_regmap.cpp:169: FAILED:
  REQUIRE( ec_own )
with expansion:
  system:0

cpp-RCP-D6. Building tests/test_faultinject with -fsanitize=address,undefined against the pre-fix header aborts:

==69696==ERROR: AddressSanitizer: container-overflow on address 0x6030000023e0
READ of size 1 at 0x6030000023e0 thread T0
    #0 rcp::faultinject::Interceptor::send(...) faultinject.hpp:88

Line 88 is the switch (rule->type). The abort reproduces from three independent call sites: the pre-existing "Slow rule adds latency" case (count = 1), the new single-shot expiry case, and — on a worker thread (thread T5) — the new 8-thread concurrent case. Against the fixed header, both test_faultinject and test_regmap pass clean under ASan/UBSan with halt_on_error=1.

That the existing suite already reproduced the abort yet CI stayed green is itself a finding: the ASan/UBSan job was scoped to test_regmap alone. It now builds and runs test_faultinject under the same flags, so this class of defect is gated rather than fixed once.

Changes

  • include/rcp/regmap.hpp — block-dependent authorization in check_write_access; header and Ep0 class comments corrected (both described the collapsed rule).
  • include/rcp/faultinject.hpp — pick_rule returns std::optional<Rule>; <optional> included.
  • tests/test_regmap.cpp — owner-grant case rebased onto the functional block; new generic-block root-only case; generic-lock case claims root first.
  • tests/test_faultinject.cpp — new same-call-expiry case (a Slow rule with count = 1 must still apply its configured latency after being retired) and a new 8-thread case retiring 256 single-shot rules.
  • .github/workflows/ci.yml — ASan/UBSan job extended to test_faultinject.
  • .fusa-reqs.json — REQ-REGMAP-005 restated with the Generic/Functional asymmetry (it encoded the buggy rule verbatim); REQ-REGMAP-006 no longer implies an owner may write the generic block; REQ-FI-005 states the copy-under-lock contract.
  • README.md / ROADMAP.md — milestone 67 added, GA renumbered to 68.

Compatibility

No wire-format change and no public API signature change; pick_rule is private, so its return type is an implementation detail. The one observable behavior change is the intended one: a non-root client's generic-config write that previously succeeded now returns UNAUTHORIZED_ACCESS. kVersion/CMakeLists.txt bump 2.22.0 → 2.23.0, the same minor-bump convention milestones 63/64/65/66 used during this pre-GA v2.x line.

…ction use-after-free

Two unrelated critical defects, both found by an independent audit of this
repository, fixed together because neither is large.

1. Privilege escalation on the generic config block (cpp-RCP-D2)

§13.1 grants an RC Client other than the ROOT_CLIENT write access only to the
functional config (EP_FUNC_config) of the endpoints allocated to it; §13.2
frames the counterpart block as the generic part of the endpoint register map,
owned by the RC Server. Ep0::check_write_access collapsed that distinction: it
applied one identical owner-based authorization test to both
ConfigBlock::Generic and ConfigBlock::Functional, with only the *lock* branch —
not the *authorization* branch — differing by block. write_generic_config
therefore succeeded for any non-root client that merely owned the target
endpoint.

That is an escalation rather than a cosmetic gap because of what the generic
block holds: hw_pin_indices (which physical pins the endpoint claims),
request_queue_size/response_queue_size, and the per-endpoint
ep_req_crc_enable/ep_ack_crc_enable/ep_response_crc_enable E2E-CRC toggles
added at v2.6.0. A non-root client could repoint an owned endpoint's hardware
pin mapping or silently switch off a safety mechanism — data the specification
reserves to the RC Server and the root client.

Authorization now depends on the targeted block: generic-block writes require
the root client, functional-block writes accept root-or-owner. `block` keeps
defaulting to ConfigBlock::Generic, which is now the stricter of the two, so a
call site that forgets to name a block fails closed. write_functional_config,
write_whole_map, check_read_access and the cpp-RCP-N2-01/N2-02 size invariants
are untouched.

tests/test_regmap.cpp's "A non-root client may only write the endpoint config
it owns" was asserting the bug as correct behavior — it wrote the *generic*
block as a non-root owner and required success. It is split in two: the owner
grant is retested against the functional block where it belongs, and a new case
pins the generic block as root-only, exercising write_generic_config and
check_write_access directly (including the default-argument path) and asserting
the refused write left the config untouched. The generic-block lock test now
claims the root client first so the lock, not the new authorization check, is
still what refuses that write.

2. Use-after-free in faultinject::Interceptor::pick_rule (cpp-RCP-D6)

pick_rule returned a Rule* into its own std::vector<Rule> and erased that very
element before returning whenever the firing exhausted the rule's count.
send() then dereferenced the erased element to read type and latency. Erasing a
trailing element does not reallocate on common libstdc++/libc++ builds, so
unsanitized builds read stale-but-mapped memory and appeared to work — which is
why the existing count-expiry test never failed. The same pointer was also a
data race: rules_ is guarded only for the duration of pick_rule(), so send()
read into the container with no lock held while another thread could be
reallocating it via add_rule, emptying it via clear_rules, or expiring rules of
its own — in a class that holds a std::mutex precisely because it is meant to
be used concurrently.

pick_rule now returns std::optional<Rule>, copied out while the lock is still
held and before any erase; send() acts on that value. Rule is four scalars, so
the copy is free.

Verification

Full local build and ctest (53/53 suites) pass. Both fixes were mutation-
checked. Reverting the regmap authorization to the pre-fix owner-only test
fails the new generic-block case. Building tests/test_faultinject with
-fsanitize=address,undefined against the pre-fix header aborts with
"AddressSanitizer: container-overflow" at the switch (rule->type) in send() —
reached from the pre-existing "Slow rule adds latency" case, from the new
single-shot expiry case, and from a worker thread in the new concurrent case —
and passes clean against the fixed header.

That the existing suite already reproduced the abort yet CI stayed green is
itself a finding: the ASan/UBSan job was scoped to test_regmap alone. It now
builds and runs test_faultinject under the same flags, so this class of defect
is gated rather than fixed once.

- regmap.hpp: block-dependent authorization in check_write_access; header and
  Ep0 class comments corrected (they described the collapsed rule).
- faultinject.hpp: pick_rule returns std::optional<Rule>; <optional> included.
- test_regmap.cpp: owner-grant case rebased onto the functional block; new
  generic-block root-only case; generic-lock case claims root first.
- test_faultinject.cpp: new same-call-expiry case (a Slow rule with count=1
  must still apply its configured latency) and a new 8-thread concurrent case
  retiring 256 single-shot rules.
- ci.yml: ASan/UBSan job extended to test_faultinject.
- .fusa-reqs.json: REQ-REGMAP-005 restated with the Generic/Functional
  asymmetry (it encoded the buggy rule verbatim); REQ-REGMAP-006 no longer
  implies an owner may write the generic block; REQ-FI-005 states the
  copy-under-lock contract.
- README.md/ROADMAP.md: milestone 67 added, GA renumbered to 68.

No wire-format change and no public API signature change; pick_rule is private.
The one observable behavior change is the intended one: a non-root client's
generic-config write that previously succeeded now returns UNAUTHORIZED_ACCESS.
kVersion/CMakeLists.txt bump 2.22.0 -> 2.23.0.

Signed-off-by: Matt <47545907+SoundMatt@users.noreply.github.com>
@SoundMatt
SoundMatt merged commit 871f62b into main Aug 1, 2026
22 checks passed
@SoundMatt
SoundMatt deleted the fix/generic-config-privesc-and-faultinject-uaf branch August 1, 2026 04:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant