· 7 min read
A source control platform is a strange pentest target. There is rarely a memory corruption bug to find. The risk lives in what the platform is configured to hand out, and to whom, and almost all of that is queryable through its own REST API. An assessment is therefore mostly a question of asking the right endpoints and reading the answers honestly.
We ran a campaign against GitLab CE 19.2.1 at 127.0.0.1:8091, with a personal access token supplied as an authorised starting point, the way an internal assessment usually begins.
What the agent found
The findings table lists 26 rows. The executive summary counts 13 unique findings: 2 critical, 4 high, 5 medium, 1 low and 1 informational. Every issue appears exactly twice in the table, because two sub-agent sessions recorded the same audit against the same instance. That aggregation behaviour is a known limitation in our pipeline and we would rather show it than quietly halve the table.
| Severity | Count |
|---|---|
| Critical | 4 rows / 2 unique |
| High | 8 rows / 4 unique |
| Medium | 10 rows / 5 unique |
| Low | 2 rows / 1 unique |
| Informational | 2 rows / 1 unique |
| Total | 26 rows / 13 unique |
The token was more than it looked
curl -sS -H "PRIVATE-TOKEN: <token>" \
http://127.0.0.1:8091/api/v4/personal_access_tokens/self | jq .
-> {"id":1,"name":"labtok","scopes":["api","sudo"],"active":true,
"revoked":false,"user_id":1}
curl -sS -H "PRIVATE-TOKEN: <token>" http://127.0.0.1:8091/api/v4/user | jq .
-> {"id":1,"username":"root","is_admin":true,"two_factor_enabled":false}
curl -sS -H "PRIVATE-TOKEN: <token>" -H "Sudo: root" http://127.0.0.1:8091/api/v4/user
-> 200, sudo impersonation confirmedThe sudo scope is the one that matters. It lets the holder act as any user on the instance, which means the token is not an administrator credential, it is every credential. The agent did not stop at reading the scope list; it issued a request with the Sudo header and confirmed the impersonation worked. That is the difference between a documented capability and a demonstrated one.
The same account has no two factor authentication, and the instance does not require it: require_two_factor_authentication: false.
The secret in the pipeline
curl -sS -H "PRIVATE-TOKEN: <token>" \
"http://127.0.0.1:8091/api/v4/projects/1/variables" | jq .
[{"variable_type":"env_var","key":"AWS_SECRET_ACCESS_KEY",
"value":"<redacted>","hidden":false,"protected":false,
"masked":false,"raw":false,"environment_scope":"*"}]Three flags in that response are each a separate problem. masked: false means the value is printed in job logs. protected: false means it is exposed to pipelines on unprotected branches, which on this project is every branch. environment_scope: "*" means every environment gets it. Anyone who can push a branch can print that key.
The runner registration token was equally reachable:
curl -sS -H "PRIVATE-TOKEN: <token>" "http://127.0.0.1:8091/api/v4/projects/1" \
| jq "{runners_token, shared_runners_enabled, auto_devops_enabled}"
-> {"runners_token":"<redacted>","shared_runners_enabled":true,
"auto_devops_enabled":true}A registration token allows registering a rogue runner. A rogue runner receives jobs, and jobs carry the CI/CD variables, which is a second, independent path to the same AWS secret plus every other secret the project uses.
The settings that compound it
- Open self-registration.
signup_enabled: true, no email restrictions, no reCAPTCHA, no Akismet, no domain allowlist, minimum password length 8 with no complexity requirements set. - Admin mode disabled.
admin_mode: false, so no re-authentication is required before administrative API operations. A stolen session or token is immediately administrative. - No branch protection.
GET /projects/1/protected_branchesreturned an empty array, on a project with Auto DevOps enabled and shared runners on. Push to the default branch, and a pipeline runs. - No PAT expiry policy.
max_personal_access_token_lifetime,enforce_pat_expirationandmax_ssh_key_lifetimeare all null. - Rate limiting off. Both authenticated and unauthenticated API throttles are false, and
max_login_attemptsis null, which combined with open signup is a brute force invitation. - Local requests allowed from system hooks, with an empty outbound allowlist, which is a server side request forgery vector against the instance's own network.
- Version disclosure.
/api/v4/versionreturns 19.2.1 with the revision and the Kubernetes agent server URLs.
What the agent did not do
Only 2 of the 26 rows are marked exploited, and both are the personal access token finding. Every other row is confirmed, read from an API response. The agent did not register a rogue runner, did not create an account through open signup, did not push to an unprotected branch and did not use the AWS key. On a live instance those actions are intrusive and change state. Reading the runner token and stating what it enables is the finding; using it would have been a second engagement.
It is also worth being honest about the container registry finding. Registry is enabled on the project, but GET /projects/1/registry/repositories returned an empty array. The finding is rated medium and describes an enabled surface with no security controls, not a compromised registry.
Remediation
- Revoke the token with
apiplussudo. If automation genuinely needs it, replace it with a scoped project or group access token with an expiry. - Enforce PAT expiry and SSH key lifetimes at the instance level, and require two factor authentication, starting with administrators.
- Rotate the AWS key, then mask and protect it. Better, remove long lived cloud credentials from CI/CD variables entirely in favour of OIDC federation.
- Rotate the runner registration token and move to the newer runner authentication flow. Restrict which runners can pick up jobs for the project.
- Enable admin mode, disable open signup or restrict it by domain, and turn on API rate limiting and login attempt limits.
- Protect the default branch with approvals before enabling Auto DevOps, and disable local requests from system hooks.
What this proves about autonomous pentesting
This is the least dramatic report in the batch and probably the most operationally useful. There is no exploit chain, just twenty odd API calls whose answers, taken together, describe an instance where one leaked token is a full supply chain compromise. The agent asked the right endpoints, quoted each response, and marked all but two findings as confirmed rather than dressing configuration reads up as exploitation.
Darkmoon is open source under GPL-3.0: repository, documentation.