Skip to content

Unsafe Kryo deserialization of Redis cache values (CWE-502) — RCE if the cache Redis is attacker-writable #2888

Description

@NinjaGPT

Summary (form: "Summary")

OBP-API caches computed values in Redis using Twitter Chill's KryoInjection, whose default Kryo pool
(ScalaKryoInstantiator.defaultPool) has registration not required and uses Objenesis
StdInstantiatorStrategy — i.e. it will instantiate arbitrary classes found in the serialized stream,
bypassing constructors. On every cache read, the raw bytes returned by Redis GET are passed straight to
KryoInjection.invert(...).

Consequently, any party who can write the OBP Redis keyspace can achieve remote code execution in the OBP-API
JVM
by planting a malicious Kryo payload (a "deserialization gadget") under a cache key that OBP later reads back.

This is a second-order / conditional vulnerability: it is not triggerable by an HTTP request alone (a request
controls only which cache key is computed, not the serialized value bytes). The realistic precondition is
write access to the cache Redis — most commonly an exposed or unauthenticated Redis instance, a shared/multi-tenant
Redis
, or any separate bug granting arbitrary Redis writes. The default Redis configuration (127.0.0.1:6379,
no password unless cache.redis.password is set) makes a misconfigured exposure plausible, and the consequence of
such a misconfiguration is upgraded from "cache tampering" to "RCE".


Severity (form: "Severity") and CVSS

Proposed: High, but explicitly gated on a precondition (cache Redis writable by attacker).

Suggested CVSS v3.1 vector (privileges-required reflects the Redis-write precondition):
CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H → ~6.8 (Medium–High)

Rationale for the non-RCE-default scoring (honest framing):

  • AC:H and PR:H capture that the attacker must already be able to write the backend Redis — not a property of an
    unauthenticated HTTP request.
  • If the deployment exposes an unauthenticated Redis on a routable interface, the effective barrier collapses and the
    practical impact is full RCE; maintainers may prefer to score that deployment scenario higher.

Affected products / versions (form: "Affected products")

  • Ecosystem: (no published package — this is an application; report against the repo)
  • Package/repo: OpenBankProject/OBP-API
  • Affected versions: at least current develop (version := "1.10.1", commit eeebff8, 2026-06-25) and prior releases
    that contain obp-api/src/main/scala/code/api/cache/Redis.scala with the KryoInjection codec. The pattern has been
    present for a long time and is also carried by sibling repos (see "Additional context").
  • Patched versions: none

Dependency that supplies the unsafe default: com.twitter %% chill-bijection % 0.9.1 (and chill-akka % 0.9.1).


Vulnerability type / CWE

  • CWE-502: Deserialization of Untrusted Data
  • (contributing) CWE-1188 / insecure default configuration of the cache Redis (no auth by default)

Proof of concept / technical details (form: "Description" / details)

Sink — obp-api/src/main/scala/code/api/cache/Redis.scala

import com.twitter.chill.KryoInjection                                  // L243

implicit def anyToByte[T](implicit m: Manifest[T]) = new Codec[T, Array[Byte]] {
  def serialize(value: T): Array[Byte] = KryoInjection(value)           // L247  cache WRITE
  def deserialize(data: Array[Byte]): T = {
    val tryDecode = KryoInjection.invert(data)                          // L254  SINK — unrestricted Kryo on Redis bytes
    tryDecode match {
      case Success(v) => v.asInstanceOf[T]
      case Failure(e) => logger.error(e); "NONE".asInstanceOf[T]
    }
  }
}
implicit val scalaCache = ScalaCache(RedisCache(url, port))             // L238

KryoInjection (chill-bijection) delegates to ScalaKryoInstantiator.defaultPool. In chill,
ScalaKryoInstantiator configures:

k.setRegistrationRequired(false)                  // arbitrary classes allowed
k.setInstantiatorStrategy(new StdInstantiatorStrategy)  // Objenesis: constructor bypassed

This is the well-known Kryo "deserialization gadget" condition (cf. CVE-2020-5413; the OWASP Deserialization Cheat
Sheet explicitly names Chill as a wrapper that leaves class registration not required by default).

Data flow

HTTP request -> provider method wrapped in Caching.memoize(Sync)WithProvider   (code/api/cache/Caching.scala L13-L35)
            -> Redis.memoize(Sync)WithRedis                                    (Redis.scala L264-L270)
            -> ScalaCache RedisCache GET <namespaced key> -> raw bytes
            -> anyToByte.deserialize(bytes) -> KryoInjection.invert(bytes)     [SINK]

Cached value types include metrics, FX rates, method-routing / endpoint-mapping / dynamic-entity lookups, auth/user
lookups, rate-limit state, etc. — all normally written by OBP itself via KryoInjection(value).

Trust boundary / why it is conditional (stated honestly)

The bytes fed to invert come from Redis GET. OBP trusts them because, in a correct deployment, only OBP writes
those keys. An HTTP attacker influences which cache key is computed (CacheKeyFromArguments.buildCacheKey) but not
the serialized value bytes. I did not find a first-order path where attacker-supplied raw bytes are stored to a
Redis key and later read back through anyToByte.deserialize. The exploit therefore requires the attacker to write the
Redis keyspace.

Redis defaults that make the precondition realistic — Redis.scala L28–L34

val url = APIUtil.getPropsValue("cache.redis.url", "127.0.0.1")
val port = APIUtil.getPropsAsIntValue("cache.redis.port", 6379)
val password = APIUtil.getPropsValue("cache.redis.password") match {
  case Full(p) if p.trim.nonEmpty => p
  case _ => null            // no auth unless explicitly configured
}

Exploit outline (authorized lab only — do NOT run against production)

  1. Attacker obtains write access to the OBP cache Redis (e.g. exposed 6379 without cache.redis.password).
  2. Attacker writes, under a key OBP will read back via memoize*WithRedis, a Kryo-serialized gadget payload
    (an object graph whose instantiation triggers code execution / SSRF via classes on OBP's classpath).
  3. OBP performs a cache read for that key → KryoInjection.invert(payload) → arbitrary class instantiation → RCE in
    the OBP JVM.
    Non-destructive validation: use a gadget that performs an out-of-band callback (DNS/HTTP) instead of real code
    execution to prove deserialization fired.

Impact

Remote code execution in the OBP-API JVM (and thus access to anything that process can reach — DB credentials,
connector secrets, etc.), conditioned on the attacker being able to write the backend Redis. Where the cache Redis
is exposed/unauthenticated, the end-to-end impact is critical; where Redis is correctly isolated and authenticated, the
path is not reachable.


Remediation (recommended)

  1. Lock down the cache deserializer. Replace KryoInjection.defaultPool with a Kryo instantiator that calls
    setRegistrationRequired(true) and registers only the known cached value types (allowlist). This removes the
    Redis-write → RCE upgrade even if Redis is compromised. Alternatively use a non-code-executing serializer
    (typed JSON) for cache values.
  2. Harden Redis by default / documentation. Require authentication for the cache Redis; never bind it to a routable
    interface; document that 127.0.0.1:6379 with no password is a development-only setting.
  3. Apply the same fix to the sibling copies of this code (see below).

Additional context — other repos carrying the same code (for the maintainers)

The identical KryoInjection cache codec appears in (confirmed via code search):

  • OpenBankProject/OBP-APIobp-api/src/main/scala/code/api/cache/Redis.scala
  • OpenBankProject/API-Explorersrc/main/scala/code/util/cache/Redis.scala (independent app; Redis defaults
    127.0.0.1:6379, no password field at all)
  • OpenBankProject/OBP-API-IIobp-api/src/main/scala/code/api/cache/Redis.scala
  • Third-party copies/derivatives: InnoScripts2/OBP-API-develop, finscaleAI/obp-API, FinworxTech/OpenBankProject,
    hkwany/OBP-API, eric-erki/OBP-API (mostly stale snapshots).

CVE request

I'd like this to receive a CVE. Please consider requesting one via GitHub's advisory workflow once you triage it
(GitHub acts as CNA for advisories on this repo). Root cause is the unsafe default in Chill's Kryo pool, but the correct
attribution here is the OBP-API product's use of KryoInjection.defaultPool for cache deserialization — this is
distinct from the underlying SnakeYAML/Kryo library CVEs. I'm happy to coordinate disclosure timing and provide a
non-destructive PoC in a private channel.


Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions