Back to Intelligence

CVE-2026-45363: ruby-jwt Empty-Key Token Forgery — Detection and Remediation Guide (Debian DLA-4787-1)

SA
Security Arsenal Team
September 20, 2026
9 min read

Debian has issued LTS advisory DLA-4787-1 for a critical vulnerability in ruby-jwt — the widely deployed Ruby implementation of the RFC 7519 JSON Web Token standard — tracked as CVE-2026-45363. The flaw allows an attacker to forge a cryptographically valid JWT signature when an application calls JWT.decode(token, '', true, algorithm: 'HS256') with an empty key. Because OpenSSL::HMAC.digest('SHA256', '', payload) happily computes a legitimate digest under an empty key — the HMAC algorithm imposes no empty-key precondition — an attacker who signs a malicious token with an empty secret produces a signature that the vulnerable library verifies as authentic.

The practical impact: any Ruby application that decodes HS256 tokens using a secret that can resolve to an empty string or nil-coerced value is exposed to full authentication bypass and token forgery. The attacker mints their own token — arbitrary user ID, arbitrary role, arbitrary expiry — and the application trusts it. This is not a denial-of-service footnote; the DoS angle in the advisory title understates the real risk, which is identity fabrication. Every Rails API, Sinatra service, and background worker that verifies JWTs with ruby-jwt needs to be audited today.

Technical Analysis

Affected Products and Platforms

  • Library: ruby-jwt (Ruby gem jwt) — versions prior to the patched release distributed via Debian DLA-4787-1. Upstream users should upgrade to the current fixed 2.x release of the gem.
  • Distribution: Debian LTS (Bullseye) — package ruby-jwt, fixed by DLA-4787-1.
  • Exposure conditions: The vulnerable code path is reached when all of the following are true:
    1. The application uses JWT.decode with verification enabled (verify = true).
    2. The algorithm is HMAC-based (HS256/HS384/HS512).
    3. The key argument evaluates to an empty string — typically because the secret is read from an environment variable, credentials store, or config value that is unset, blank, or defaulted to ''.

Root Cause

The defect is a missing precondition check, not weak cryptography. HMAC per RFC 2104 permits a zero-length key, and OpenSSL's OpenSSL::HMAC.digest implements it faithfully. ruby-jmt historically passed the caller-supplied key straight through to OpenSSL without rejecting empty keys. The result is a symmetric-key oracle with a universally known key: anyone can compute HMAC-SHA256('', payload) locally and attach it to a forged header/payload pair. The attack chain is trivial:

  1. Attacker identifies an endpoint accepting JWTs (often visible via WWW-Authenticate: Bearer responses or leaked client code).
  2. Attacker constructs header.payload with chosen claims (sub, role: admin, far-future exp).
  3. Attacker computes the signature as Base64URL(HMAC-SHA256('', header.payload)) — no secret material required.
  4. The application calls JWT.decode(forged, '', true, algorithm: 'HS256'), verification succeeds, and the forged identity is accepted.

The same path is reached regardless of how the empty key arises — a missing ENV['JWT_SECRET'], a misconfigured secrets manager lookup, a fallback default of '', or a test-mode flag shipped to production. The library should have refused; it didn't.

Severity and Exploitation Status

No public CVSS vector had been published at the time of the Debian advisory, but the defender-side assessment is straightforward: unauthenticated remote token forgery leading to authentication bypass and privilege escalation is functionally a Critical/High finding in any affected deployment. Exploitation requires no special conditions beyond the empty-key misconfiguration — no race windows, no user interaction, no local access. There is no confirmed CISA KEV listing at publication time, but the technique is deterministic and the PoC is effectively a one-liner; treat exploitability as practical, not theoretical, and assume scanning for Ruby JWT endpoints will follow public disclosure.

Detection & Response

Detection here splits into two layers: (1) vulnerability surface discovery — find every host running a vulnerable ruby-jwt before attackers do; and (2) abuse detection — catch the post-forgery behaviors that follow when an attacker parlays a minted admin token into code execution. Note that the forgery itself is cryptographically indistinguishable from legitimate traffic at the network layer; your best signal is the empty-key condition on disk and anomalous application behavior after authentication.

Sigma Rules

YAML
---
title: Ruby Application Server Spawning Shell or Interpreter
description: Detects Ruby web/application processes (ruby, puma, passenger, sidekiq) spawning shells or script interpreters. A forged JWT yielding admin access is frequently followed by in-app command execution features or webshell-style abuse, making a shell under a Ruby server a high-fidelity compromise signal.
author: Security Arsenal
date: 2026/05/20
status: experimental
references:
  - https://linuxsecurity.com/advisories/deblts/debian-lts-dla-4787-1-ruby-jwt
  - https://attack.mitre.org/techniques/T1059/
logsource:
  category: process_creation
  product: linux
detection:
  selection_parent:
    ParentImage|endswith:
      - '/ruby'
      - '/puma'
      - '/passenger'
      - '/sidekiq'
      - '/unicorn'
  selection_child:
    Image|endswith:
      - '/sh'
      - '/bash'
      - '/dash'
      - '/zsh'
      - '/python'
      - '/python3'
      - '/perl'
      - '/curl'
      - '/wget'
      - '/nc'
      - '/ncat'
  condition: selection_parent and selection_child
falsepositives:
  - Legitimate deployment scripts or rake tasks invoking system commands (rare under a live web worker)
level: high
---
title: Installation or Downgrade of ruby-jwt Gem via CLI
description: Detects interactive installation of the jwt gem outside of normal CI/CD. Attackers or misconfigurations introducing a vulnerable jwt version (including Gemfile edits pinning old releases) are worth auditing during the DLA-4787-1 remediation window.
author: Security Arsenal
date: 2026/05/20
status: experimental
references:
  - https://linuxsecurity.com/advisories/deblts/debian-lts-dla-4787-1-ruby-jwt
logsource:
  category: process_creation
  product: linux
detection:
  selection_img:
    Image|endswith:
      - '/gem'
      - '/bundle'
      - '/bundler'
  selection_cli:
    CommandLine|contains:
      - 'install'
      - 'update'
  selection_gem:
    CommandLine|contains:
      - 'jwt'
  condition: selection_img and selection_cli and selection_gem
falsepositives:
  - Developer workstations and CI pipelines performing legitimate dependency management
level: low

KQL (Microsoft Sentinel / Defender)

This hunts two things in one workflow: (a) Ruby server processes spawning shells on endpoints (via Defender for Endpoint or AMA-ingested Syslog), and (b) hosts where vulnerable ruby-jwt gem versions are present on disk. Run the file-inventory portion broadly across your Linux estate — that is your patch prioritization list.

KQL — Microsoft Sentinel / Defender
// Part 1: Ruby application servers spawning shells/interpreters (post-forgery execution)
DeviceProcessEvents
| where TimeGenerated > ago(7d)
| where InitiatingProcessFileName has_any ("ruby", "puma", "passenger", "sidekiq", "unicorn")
| where FileName has_any ("sh", "bash", "dash", "python", "python3", "perl", "curl", "wget", "nc", "ncat")
| project TimeGenerated, DeviceName, InitiatingProcessFileName, InitiatingProcessCommandLine,
          FileName, ProcessCommandLine, AccountName
| order by TimeGenerated desc;

// Part 2: Same behavior via Syslog ingestion (hosts without MDE)
Syslog
| where TimeGenerated > ago(7d)
| where SyslogMessage has_any ("ruby", "puma", "sidekiq")
| where SyslogMessage has_any ("/bin/sh", "/bin/bash", "curl ", "wget ", "python")
| project TimeGenerated, Computer, ProcessName, SyslogMessage
| order by TimeGenerated desc;

// Part 3: Inventory hosts with ruby-jwt gem installed (version triage feed for patching)
DeviceFileEvents
| where TimeGenerated > ago(14d)
| where FolderPath has "gems/jwt-"
| extend GemVersion = extract(@"gems/jwt-([0-9.]+)", 1, FolderPath)
| where isnotempty(GemVersion)
| summarize LastSeen = max(TimeGenerated), GemVersions = make_set(GemVersion) by DeviceName
| order by DeviceName asc

Velociraptor VQL

Use this hunt artifact to enumerate every installed ruby-jwt copy on a Linux endpoint — gem directories, Bundler vendored paths, and Gemfile.lock pins — so you can compare against the fixed version from DLA-4787-1. Parsing version.rb directly avoids depending on the gem binary being present.

VQL — Velociraptor
-- Hunt: Enumerate installed ruby-jwt versions and Gemfile.lock pins
-- Purpose: Identify hosts running ruby-jwt versions vulnerable to CVE-2026-45363
LET gem_dirs = SELECT FullPath
FROM glob(globs=['/var/lib/gems/*/gems/jwt-*/lib/jwt/version.rb',
                 '/usr/share/rubygems-integration/*/gems/jwt-*/lib/jwt/version.rb',
                 '/opt/*/vendor/bundle/ruby/*/gems/jwt-*/lib/jwt/version.rb',
                 '/home/*/.rbenv/versions/*/lib/ruby/gems/*/gems/jwt-*/lib/jwt/version.rb',
                 '/home/*/.rvm/gems/*/gems/jwt-*/lib/jwt/version.rb'])

LET versions = SELECT FullPath,
       parse_string_with_regex(string=read_file(filename=FullPath, length=4096),
                               regex="VERSION = '(?P<Version>[0-9.]+)'").Version AS JwtVersion
FROM gem_dirs

LET lockfiles = SELECT FullPath,
       parse_string_with_regex(string=read_file(filename=FullPath, length=1048576),
                               regex="jwt \((?P<Pinned>[0-9.]+)\)").Pinned AS PinnedVersion
FROM glob(globs=['/srv/**/Gemfile.lock', '/opt/**/Gemfile.lock', '/home/**/Gemfile.lock', '/var/www/**/Gemfile.lock'])
WHERE PinnedVersion

SELECT * FROM versions
UNION ALL
SELECT FullPath, PinnedVersion AS JwtVersion FROM lockfiles

Remediation Script (Bash)

Run this on Debian LTS hosts to apply the vendor patch and audit application code for the dangerous empty-key decode pattern. The grep audit is the important part — patching the library does not fix a codebase that passes an empty secret on purpose.

Bash / Shell
#!/usr/bin/env bash
# CVE-2026-45363 / DLA-4787-1 remediation and audit script (Debian LTS)
set -euo pipefail

echo "=== [1/4] Applying Debian LTS patched ruby-jwt package ==="
apt-get update
apt-get install --only-upgrade -y ruby-jwt || echo "ruby-jwt not installed via apt (gem-managed installs must be upgraded separately)"

echo "=== [2/4] Inventorying installed jwt gem versions ==="
if command -v gem >/dev/null 2>&1; then
  gem list jwt 2>/dev/null | grep -i '^jwt' || echo "jwt gem not found via system gem"
fi
find /var/lib/gems /usr/local/lib/ruby /opt -type d -name 'jwt-*' -path '*gems*' 2>/dev/null | sort -u

echo "=== [3/4] Scanning codebases for empty-key JWT.decode patterns ==="
# Flags decode calls where the key argument is a literal empty string or an ENV var
# that may resolve to empty. Review every hit manually.
grep -rEn "JWT\.decode\s*\([^,]+,\s*''" /srv /opt /var/www /home 2>/dev/null --include='*.rb' || echo "No literal empty-key decode calls found"
grep -rEn "JWT\.decode\s*\([^,]+,\s*ENV\[" /srv /opt /var/www /home 2>/dev/null --include='*.rb' | head -50 || true

echo "=== [4/4] Verifying environment secrets are populated ==="
for v in JWT_SECRET JWT_SECRET_KEY RAILS_MASTER_KEY; do
  if [ -z "${!v:-}" ]; then echo "WARNING: \$${v} is unset or empty"; else echo "OK: \$${v} is set (length ${#v} placeholder)"; fi
done

echo "Remediation complete. Restart Ruby application services to load the patched library."

Remediation

  1. Patch immediately via Debian LTS: apt-get update && apt-get install --only-upgrade ruby-jwt. The fixed package is distributed under DLA-4787-1. Restart every Ruby service afterward — gems are loaded into memory at process start, so an un-restarted puma/sidekiq worker remains vulnerable after the package upgrade.
  2. Upgrade gem-managed installs: For Bundler deployments, bump the jwt gem to the current fixed upstream release in your Gemfile/Gemfile.lock and redeploy. Do not pin to legacy versions.
  3. Fix the empty-key precondition in application code (defense in depth): Even after patching, enforce at the application layer that JWT secrets are non-empty and meet minimum entropy before calling JWT.decode. Fail closed at boot if ENV['JWT_SECRET'] (or equivalent) is unset or empty — never default to ''.
  4. Prefer asymmetric algorithms where feasible: RS256/ES256 eliminate the shared-secret handling class of errors entirely; a missing private key fails loudly rather than silently validating under a degenerate key.
  5. Audit for prior exploitation: Because forged tokens are indistinguishable from legitimate ones, review application logs for anomalous privileged actions — admin endpoints hit without a corresponding session/login event, sub claims for accounts that never authenticated, or tokens with implausible iat/exp values. Escalate any hits to your IR process.
  6. Verify in staging: Add a regression test asserting that JWT.decode(token, '', true, algorithm: 'HS256') raises rather than verifies. This permanently guards against reintroduction of the pattern.

Related Resources

Security Arsenal Incident Response Services AlertMonitor Platform Book a SOC Assessment incident-response Intel Hub

Is your security operations ready?

Get a free SOC assessment or see how AlertMonitor cuts through alert noise with automated triage.