OpenBankProject / OpenBankProject/OBP-API

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

Open
#2,888 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Scala
Stars
1.7k
Forks
482
Avg merge
1d 12h
Merged PRs (30d)
15

Description

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.


Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with obp-api/src/main/scala/code/api/cache/Redis.scala, especially the KryoInjection codec and Redis configuration, then trace the cache flow in code/api/cache/Caching.scala. Review how cached value types are handled and add coverage for safe deserialization; done means attacker-controlled Redis bytes cannot trigger unrestricted class instantiation and the cache behavior remains functional.

Written by the indexing model from the issue text.

Assessment

Tech stack
redis, scala
Domain
backend, databases, security
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
38/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.