Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .fusa-reqs.json
Original file line number Diff line number Diff line change
Expand Up @@ -763,7 +763,7 @@
{
"id": "REQ-FI-005",
"title": "Count-based rule expires after N firings",
"text": "A rule with count > 0 shall auto-expire after count applications and be removed from the active list.",
"text": "A rule with count > 0 shall auto-expire after count applications and be removed from the active list. The application that retires a rule shall still take full effect, and rule selection shall yield a value copy taken under the interceptor's own lock rather than a reference into the mutable rule list, so that no caller reads a rule element that has been erased or relocated.",
"standard": "iso26262",
"level": "ASIL-B",
"asil": "ASIL-B"
Expand Down Expand Up @@ -2099,15 +2099,15 @@
{
"id": "REQ-REGMAP-005",
"title": "Per-endpoint write restriction for non-root clients",
"text": "Ep0::check_write_access (and the write_generic_config/write_functional_config calls built on it) shall permit a non-root client to write only the endpoint config it owns per Ep0::set_endpoint_owner, shall return RegMapErrc::unauthorized_access for any other endpoint, shall always permit the root client, and shall return RegMapErrc::invalid_parameter for an out-of-range endpoint id.",
"text": "Ep0::check_write_access (and the write_generic_config/write_functional_config calls built on it) shall permit a non-root client to write only the FUNCTIONAL config block of an endpoint it owns per Ep0::set_endpoint_owner (TC18 section 13.1 grants a non-ROOT_CLIENT write access to EP_FUNC_config only; section 13.2 places the generic block under the RC Server's ownership), shall return RegMapErrc::unauthorized_access for a non-root client writing the GENERIC config block of any endpoint including one it owns, shall return RegMapErrc::unauthorized_access for a non-root client writing either block of an endpoint it does not own, shall always permit the root client, and shall return RegMapErrc::invalid_parameter for an out-of-range endpoint id.",
"standard": "iso26262",
"level": "ASIL-B",
"asil": "ASIL-B"
},
{
"id": "REQ-REGMAP-006",
"title": "Config-block locks are enforced independently by EP0",
"text": "Ep0::write_generic_config shall return RegMapErrc::locked_mem_access once ServerLifecycle::generic_config_locked is true, and Ep0::write_functional_config shall return RegMapErrc::locked_mem_access once ServerLifecycle::functional_config_locked is true, even when called by an otherwise-authorized owning or root client.",
"text": "Ep0::write_generic_config shall return RegMapErrc::locked_mem_access once ServerLifecycle::generic_config_locked is true, and Ep0::write_functional_config shall return RegMapErrc::locked_mem_access once ServerLifecycle::functional_config_locked is true, even when called by a client that REQ-REGMAP-005 would otherwise authorize for that block.",
"standard": "iso26262",
"level": "ASIL-B",
"asil": "ASIL-B"
Expand Down
33 changes: 24 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,23 @@ jobs:
find tests -name '*.cpp' | \
xargs clang-tidy -p build --warnings-as-errors='*'

# ── ASan/UBSan (regmap) ───────────────────────────────────────────────────────
# Scoped to test_regmap: rcp/regmap.hpp's Ep0 does raw std::vector<T>
# indexing across per-endpoint access-control checks and the config
# vectors those checks gate (cpp-RCP-N2-01/N2-02, issues #64/#65), so this
# is the header most worth sanitizer coverage on a fast, gating job. This
# is additive to build-and-test above, not a replacement for it.
# ── ASan/UBSan (regmap + faultinject) ─────────────────────────────────────────
# Scoped to the two suites whose headers hand out raw references into
# containers they also mutate:
#
# - rcp/regmap.hpp's Ep0 does raw std::vector<T> indexing across
# per-endpoint access-control checks and the config vectors those checks
# gate (cpp-RCP-N2-01/N2-02, issues #64/#65).
# - rcp/faultinject.hpp's Interceptor picks a rule out of a std::vector it
# erases from in the same pass (cpp-RCP-D6). That defect was live and
# the *existing* test_faultinject cases already reproduced it under
# ASan — this job simply wasn't running them, which is why CI stayed
# green. Keeping it in scope makes that class of defect gated rather
# than fixed once.
#
# This is additive to build-and-test above, not a replacement for it.
asan-ubsan-regmap:
name: ASan/UBSan (regmap)
name: ASan/UBSan (regmap + faultinject)
runs-on: ubuntu-22.04
needs: build-and-test
steps:
Expand All @@ -239,15 +248,21 @@ jobs:
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address,undefined" \
-G Ninja

- name: Build test_regmap
run: cmake --build build-asan --target test_regmap --parallel
- name: Build test_regmap and test_faultinject
run: cmake --build build-asan --target test_regmap test_faultinject --parallel

- name: Run test_regmap under ASan/UBSan
env:
ASAN_OPTIONS: halt_on_error=1
UBSAN_OPTIONS: halt_on_error=1,print_stacktrace=1
run: ./build-asan/tests/test_regmap

- name: Run test_faultinject under ASan/UBSan
env:
ASAN_OPTIONS: halt_on_error=1
UBSAN_OPTIONS: halt_on_error=1,print_stacktrace=1
run: ./build-asan/tests/test_faultinject

# ── LCOV coverage ─────────────────────────────────────────────────────────────
coverage:
name: Coverage (LCOV)
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
cmake_minimum_required(VERSION 3.21)

project(cpp-rcp
VERSION 2.22.0
VERSION 2.23.0
DESCRIPTION "Remote Control Protocol for automotive zonal architecture (C++)"
HOMEPAGE_URL "https://github.com/SoundMatt/cpp-RCP"
LANGUAGES CXX
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ reference.
|---|---|
| `<rcp/avtp.hpp>` | TC18 wire codec, framing half — IEEE 1722 AVTPDU (NTSCF/TSCF) header framing, `StreamId`, `ByteBusId` |
| `<rcp/acf.hpp>` | TC18 wire codec, message half — ACF_ABB/ACF_GBB message format, `AcfMessageInfo`. ACF_ABB Message Info is 8 contiguous octets; ACF_GBB is 16, with the 64-bit `message_timestamp` spliced between the header's two quadlets (octets 0–3 quadlet 0, 4–11 timestamp, 12–15 quadlet 1) — corrected in v2.22.0, see ROADMAP.md milestone 66 |
| `<rcp/regmap.hpp>` | RC Server register-map model (generic + functional config, EP0) |
| `<rcp/regmap.hpp>` | RC Server register-map model (generic + functional config, EP0). The two config blocks differ in who may write them: the generic block is the RC Server's own and is root-client-only, while a non-root client may write the functional block of an endpoint allocated to it (§13.1/§13.2) — corrected in v2.23.0, see ROADMAP.md milestone 67 |
| `<rcp/lifecycle.hpp>` | RC Server lifecycle state machine |
| `<rcp/request.hpp>` | Conditional-request taxonomy (compound, triggered, timed, chained, ...) and sequencers |
| `<rcp/rcp.hpp>` | Shared primitives used codebase-wide: the `Errc` sentinel error category, `Context` (a `relay::Context` alias), and `Loan` (a generic RAII buffer-loan holder) |
Expand Down
89 changes: 88 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ work, since every endpoint type's functional config block depends on it.
| **Phase 16** | v2.20.0 | TC18 conformance fix pass II | `acf_msg_length` actually computed; multiple-requests-in-one-frame (§12.9.1.1) supported; error responses carry a Table 27 error code; GPIO exact-length payload check; ISELED gains a real wire codec; PWM_IN rising/falling-edge triggers; `control_data_length` validated |
| **Phase 16** | v2.21.0 | L2 (native Ethernet) transport + UDP Annex J conformance fix | New `l2.hpp` raw-Ethernet transport (EtherType 0x22F0); `udp.hpp` gains Annex J's 4-byte encapsulation sequence number and port 17221 default |
| **Phase 16** | v2.22.0 | ACF_GBB `message_timestamp` wire-position fix | The 64-bit `message_timestamp` is spliced *between* the ACF Message Info header's two quadlets (octet 4), not appended after both (octet 8); `e2e.hpp` CRC coverage follows; wire-breaking for ACF_GBB only |
| **Phase 16** | v2.23.0 | §13.1 config-block privilege separation + fault-injection use-after-free | `regmap::Ep0` generic config is root-client-only (owning an endpoint grants the *functional* block only); `faultinject::pick_rule` no longer hands `send()` a pointer to an erased vector element; ASan/UBSan CI job extended to `test_faultinject` |
| **Phase 16** | v3.0.0 | **TC18 RCP — General Availability** | First release where cpp-RCP *is* the OPEN Alliance TC18 Remote Control Protocol |

---
Expand Down Expand Up @@ -2096,7 +2097,93 @@ strictly a move toward the interoperability GA requires; `kVersion`/
-bump convention milestones 63/64/65 used for their own wire-format
corrections during this pre-GA v2.x line.

### 67. TC18 RCP — General Availability (v3.0.0)
### 67. §13.1 Config-Block Privilege Separation + Fault-Injection Use-After-Free (v2.23.0)

Two unrelated defects, both found by an independent audit of this
repository, both fixed together because both are critical and neither is
large: one lets a non-root RC Client write register-map data the
specification reserves to the ROOT_CLIENT, the other is a genuine
use-after-free.

**The access-control bug (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. `regmap::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. So
`write_generic_config` succeeded for any non-root client that merely owned
the target endpoint.

That is a privilege escalation, not 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_rsp_crc_enable`
E2E-CRC toggles added at v2.6.0. A non-root client could therefore repoint
an endpoint's hardware pin mapping or silently switch off a safety
mechanism on an endpoint it happens to own — all data the specification
puts under the RC Server's and root client's exclusive control.

The fix is the asymmetry itself: authorization now depends on the targeted
block, so generic-block writes require the root client and 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 pre-existing test `tests/test_regmap.cpp` case "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, checking both `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 is still what refuses that write rather than
the new authorization check masking it.

**The use-after-free (cpp-RCP-D6).** `faultinject::Interceptor::pick_rule`
returned a `Rule*` pointing 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 exactly 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. For cpp-RCP-D6 that check is the more
interesting one: 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
the 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 merely fixed once.

**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.

### 68. TC18 RCP — General Availability (v3.0.0)

- First release where cpp-RCP's `RCP` conforms to the OPEN Alliance TC18
Remote Control Protocol Specification at the wire level: an RC Client
Expand Down
45 changes: 36 additions & 9 deletions include/rcp/faultinject.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
#include <atomic>
#include <chrono>
#include <mutex>
#include <optional>
#include <thread>
#include <vector>

Expand Down Expand Up @@ -82,7 +83,11 @@ class Interceptor {
acf::AcfMessageInfo& out, std::vector<uint8_t>& out_payload) {
if (closed_.load(std::memory_order_acquire)) return ErrClosed;

Rule* rule = pick_rule();
// pick_rule returns a *value* copy rather than a pointer into
// rules_: the element it describes may already have been erased by
// the time this function reads it, and rules_ is only mutex-guarded
// inside pick_rule() (cpp-RCP-D6 — see pick_rule below).
const std::optional<Rule> rule = pick_rule();
if (!rule) return inner_(ctx, req, payload, out, out_payload);

switch (rule->type) {
Expand Down Expand Up @@ -118,22 +123,44 @@ class Interceptor {
std::mutex mu_;
std::vector<Rule> rules_;

// Returns the first active rule (or nullptr) and decrements its count.
Rule* pick_rule() {
// Returns a copy of the first active rule (or std::nullopt) after
// accounting for this firing against its count.
//
// The copy is not incidental (cpp-RCP-D6). This used to return `Rule*`
// pointing at the live vector element, which was wrong twice over:
//
// 1. Use-after-free. A rule whose count is exhausted by *this* firing is
// erased here, and the pointer returned to send() then referred to an
// erased element — send() dereferences it immediately (rule->type,
// rule->latency). erase() of a trailing element does not reallocate
// on common libstdc++/libc++ builds, so an unsanitized build usually
// read stale-but-mapped memory and appeared to work; AddressSanitizer
// reports it as a container-overflow.
// 2. Data race. rules_ is only guarded for the duration of this
// function; the lock is released on return, so a pointer into rules_
// was read by send() with no lock held while another thread could be
// concurrently add_rule()ing (reallocating the buffer),
// clear_rules()ing, or expiring rules of its own. This class holds a
// std::mutex precisely because it is meant to be used concurrently.
//
// Copying the Rule out while the lock is still held closes both: the
// value send() acts on is its own, and it stays valid however rules_ is
// subsequently mutated. Rule is four scalars, so the copy is free.
std::optional<Rule> pick_rule() {
std::lock_guard<std::mutex> lk(mu_);
for (auto it = rules_.begin(); it != rules_.end(); ) {
if (it->count == 0) {
it = rules_.erase(it);
continue;
}
Rule* r = &(*it);
++r->fired;
if (r->count > 0 && r->fired >= r->count) {
it = rules_.erase(it);
++it->fired;
const Rule picked = *it; // copy out before any possible erase
if (picked.count > 0 && picked.fired >= picked.count) {
rules_.erase(it);
}
return r;
return picked;
}
return nullptr;
return std::nullopt;
}
};

Expand Down
Loading
Loading