Workflow Exploit Challenge: Lessons From GitHub’s Call to Hacktion

GitHub’s recent Call to Hacktion CTF invited participants to find and exploit a vulnerability in a dedicated private repository, turning read-only access into write access through a deliberately flawed GitHub Actions workflow. Nearly 350 players took part, with 54 solving the challenge within the time limit.

First to finish was Ngo Wei Lin (@Creastery), who completed the exploit in 1 hour, 40 minutes, and 7 seconds after the challenge was made public. Their solution—and the creative approaches of other participants—highlights a critical security point: workflow code should be treated as privileged code, especially when it processes untrusted input.

The Vulnerability at the Core

The challenge repository gave contestants read-only access. The goal was to locate and trigger a vulnerability in a GitHub Actions workflow to gain write access to the main branch. The flaw was hidden in plain sight: a templated JavaScript injection on line 30 of the workflow file.

name: log and process issue comments
on:
  issue_comment:
    types: [created]

jobs:
  issue_comment:
    name: log issue comment
    runs-on: ubuntu-latest
    steps:
      - id: comment_log
        name: log issue comment
        uses: actions/github-script@v3
        env:
          COMMENT_BODY: ${{ github.event.comment.body }}
          COMMENT_ID: ${{ github.event.comment.id }}
        with:
          github-token: "deadc0de"
          script: |
            console.log(process.env.COMMENT_BODY)                  // line 20
            return process.env.COMMENT_ID
          result-encoding: string
      - id: comment_process
        name: process comment
        uses: actions/github-script@v3
        timeout-minutes: 1
        if: ${{ steps.comment_log.outputs.COMMENT_ID }}
        with:
          script: |
            const id = ${{ steps.comment_log.outputs.COMMENT_ID }} // line 30
            return ""
          result-encoding: string

The harder part was figuring out how to actually trigger that injection. Line 20 of the workflow logs user-controlled input—an issue comment—to stdout via console.log. On its own, that seems harmless. But GitHub Actions runners inspect stdout for workflow commands unless they’re explicitly disabled.

Workflow commands can be used to pass values between steps, such as via the set-output command. If you log untrusted data without disabling command processing, an attacker can inject commands through the log output. GitHub deprecated some of the more security-sensitive commands in 2020, but in many use patterns, logging untrusted input can still lead to unexpected behavior.

In the challenge, the templated code on line 30 was driven by steps.comment_log.outputs.COMMENT_ID—which was populated from the previous step’s stdout. By leaving an issue comment containing a crafted set-output command, players could control the contents of that output variable and thus the JavaScript that got executed.

How @Creastery’s Winning Comment Worked

@Creastery’s solution began with a single comment on their challenge repository:

That comment resulted in a direct commit to the main branch, visible here:

The comment itself starts with :set-output name=COMMENT_ID::, which hijacks the COMMENT_ID output variable. Everything after that prefix becomes the injected JavaScript payload. The payload closes the initial const id = assignment with a value of 1, then adds arbitrary code that reuses the existing github-script Action context to push a new commit via the GitHub REST API:

  • The injected code makes a PUT request to /repos/{owner}/{repo}/contents/{path}, targeting the repository's README.md.
  • It authenticates automatically because the workflow runtime has an active GitHub API token with repository-level permissions.
  • A sha value is included to indicate the file being overwritten, so the commit succeeds without a merge conflict.

The exploit works because the templated code is executed inside a workflow that holds full write privileges to the repository. Injected JavaScript that reuses the existing Action setup inherits those privileges automatically.

Style Points for Safely Merging an Exploit

While @Creastery took the direct route, another solver caught GitHub’s attention with a more ceremonious approach. @m-messiah used his code injection to prepare a pull request—and then merged that PR into main rather than committing directly. The result was a successful exploit executed entirely through proper branch-etiquette.

It’s a reminder that even in a CTF, there’s more than one path to the flag—and that the same workflow-feature powering automation can be abused in surprisingly elegant ways if input sanitization and workflow command control are overlooked.

Treating workflows as privileged code

The key takeaway from this challenge is that GitHub Workflows should be handled as privileged code in nearly every situation. That means taking a hard look at any potentially untrusted inputs, mapping out the actual privileges granted by different Workflow triggers, and sticking to the official hardening guidance for GitHub Actions wherever possible.

The challenge drew a large field of participants who worked through the scenarios. Thanks to everyone who took part:

Avatar of @0x77dev Avatar of @0xw01f Avatar of @4390c336 Avatar of @95ych Avatar of @A-ZaWaYe Avatar of @A1ex-13 Avatar of @ASVKVINAYAK Avatar of @AaronAtDuo Avatar of @Afvanjaffer Avatar of @Amarnathcdj Avatar of @Andrewgl22 Avatar of @AnkilP Avatar of @AnomDevgun Avatar of @AnshumanFauzdar Avatar of @Arun-Sunny Avatar of @AshishKapoor Avatar of @Aurous Avatar of @AustinTice Avatar of @AydenB Avatar of @Barathrajsbr Avatar of @Bigtalljosh Avatar of @Bouldersky Avatar of @BrainIs404 Avatar of @CamTosh Avatar of @ChampionBuffalo1 Avatar of @ChoKyuWon Avatar of @ChrisGarrett23 Avatar of @CodeWithAlvin Avatar of @Creastery Avatar of @DSchalla Avatar of @Dammmien Avatar of @DanielChuDC Avatar of @DevShahzad Avatar of @DeviaVir Avatar of @DolphinPhishing Avatar of @EGaddd Avatar of @Ex1st3nti4lCr1s1s Avatar of @FallenFoil Avatar of @FireFart Avatar of @GrantBirki Avatar of @GregTCLTK Avatar of @GregoireW Avatar of @Harshal0902 Avatar of @Himanshukr000 Avatar of @ISnackable Avatar of @IcarusCodes Avatar of @IdoHadar Avatar of @IshRaj Avatar of @JT117 Avatar of @JamesPatrickGill Avatar of @Janberkb Avatar of @JaviMJ Avatar of @JettChenT Avatar of @KamalAres Avatar of @KethQv Avatar of @KevinSJ Avatar of @Kiddidddd Avatar of @LV Avatar of @LanglaitC Avatar of @LloydTao Avatar of @MR-A0101 Avatar of @Moonlight-hello Avatar of @MrGithub2021 Avatar of @NAVHITS Avatar of @NGUYENTRONGDAT123 Avatar of @NetPenguins Avatar of @NiklasTerhorst Avatar of @NishantJoshi00 Avatar of @Paradise123-bot-lang Avatar of @PiotrRut Avatar of @QuangNguyenVinh Avatar of @Raul6469 Avatar of @Retr0-01 Avatar of @Ri7Sh Avatar of @RiRa12621 Avatar of @RitwikGopi Avatar of @RobDukarski Avatar of @Rocksus Avatar of @RonanLagan Avatar of @SAOMDVN Avatar of @Sijisu Avatar of @Skeeww Avatar of @SmoothMaverick Avatar of @Soham3-1415 Avatar of @Sooraj-s-98 Avatar of @SplittyDev Avatar of @TBgHg Avatar of @TarunShashank Avatar of @TheHackerCoding Avatar of @TheoMokos Avatar of @Toubster Avatar of @Tyrael Avatar of @V1NT4G3K0D3 Avatar of @VaiTon Avatar of @Veershah26 Avatar of @Wazbat Avatar of @XV1R Avatar of @Xeoth Avatar of @a-a-ron Avatar of @aagallag Avatar of @aashutoshrathi Avatar of @abbathaw Avatar of @adarsh1405 Avatar of @adithyabsk Avatar of @adithyasunil26 Avatar of @adnathanail Avatar of @adrianoapj Avatar of @ajh-sr Avatar of @ajithjunneti Avatar of @ajmilazzo Avatar of @alexrothenberg Avatar of @alphaX86 Avatar of @alsebr Avatar of @anandrajaram21 Avatar of @anandvalasseri Avatar of @aneeshdua Avatar of @antoinet Avatar of @apumax-1 Avatar of @apuyou Avatar of @aqua95 Avatar of @az9702w Avatar of @berviantoleo Avatar of @bgalek Avatar of @binarytrails Avatar of @bjansen Avatar of @blukid Avatar of @bramkragten Avatar of @cailloumajor Avatar of @callmekatootie Avatar of @chinggg Avatar of @chitoge Avatar of @chq-matteo Avatar of @chr0x6eos Avatar of @chriswood-cc Avatar of @chukkyiii Avatar of @cji Avatar of @cobianwuna Avatar of @crazymoose77756 Avatar of @daetest Avatar of @danechitoaie Avatar of @danielpetrica Avatar of @darkpanda08 Avatar of @dbeezt Avatar of @deadpixxl Avatar of @deniszh Avatar of @devhorizon53 Avatar of @dgaponov Avatar of @dhrumilp15 Avatar of @digitalwolframbler Avatar of @drwggm Avatar of @dwisiswant0 Avatar of @ebubekirtrkr Avatar of @echo10032 Avatar of @ejouv001 Avatar of @elit8888 Avatar of @emyei Avatar of @eric-winkler Avatar of @erjadi Avatar of @errietta Avatar of @evandrix Avatar of @evilpacket Avatar of @ewized Avatar of @eylamm Avatar of @f-barth Avatar of @f1ames Avatar of @fadhilthomas Avatar of @fcasal Avatar of @fegge Avatar of @foster Avatar of @franek Avatar of @frilox042 Avatar of @frunkad Avatar of @g105b Avatar of @gaffneyd4 Avatar of @georgettica Avatar of @ghctf2021 Avatar of @ginkoid Avatar of @guyb1 Avatar of @hadrianbs Avatar of @harshzalavadiya Avatar of @hax3xploit Avatar of @hellospacecorgi Avatar of @himanshu007-creator Avatar of @hmz99 Avatar of @iam-abbas Avatar of @iamansoni Avatar of @iayushvarshney Avatar of @igorvoltaic Avatar of @imoisharma Avatar of @intrigus-lgtm Avatar of @isaidnocookies Avatar of @itspacchu Avatar of @iuryoliv Avatar of @ivan23kor Avatar of @jacklagare Avatar of @jakereps Avatar of @jamespeapen Avatar of @jarrodconnolly Avatar of @jasondantuma Avatar of @jasonericdavis Avatar of @jasongautama Avatar of @jatindhankhar Avatar of @jazibobs Avatar of @jennysharps Avatar of @jhnnsg Avatar of @jmatom Avatar of @jmthvt Avatar of @joaolaranjo Avatar of @johncorbin36 Avatar of @jungsoo-shim Avatar of @just-hunter3 Avatar of @juzzeth Avatar of @jvmvl Avatar of @kahla-sec Avatar of @karma9874 Avatar of @khh-ini Avatar of @killshot13 Avatar of @klassiker Avatar of @kmhalpin Avatar of @knowbibek Avatar of @konstruktoid Avatar of @kristoferanandita Avatar of @ksaid39 Avatar of @kunalnagar Avatar of @lanavarrogs Avatar of @lapt0r Avatar of @leMedi Avatar of @leomoot Avatar of @listenToRipley Avatar of @lu-zen Avatar of @ludeeus Avatar of @lukeflima Avatar of @luxterful Avatar of @m-messiah Avatar of @maarlen Avatar of @maeserichar Avatar of @malik0011 Avatar of @malwarebo Avatar of @manbonpan Avatar of @manishkumarr1017 Avatar of @marinelli Avatar of @matthewmaclay Avatar of @maxam2017 Avatar of @mazzaccaro Avatar of @mbiesiad Avatar of @mcharo Avatar of @med42 Avatar of @meetmangukiya Avatar of @meroupatate Avatar of @mheap Avatar of @mnao305 Avatar of @mosslilley Avatar of @mpslanker Avatar of @msimecek Avatar of @mxschmitt Avatar of @my-demo-github Avatar of @n-y-kim Avatar of @naortalmor1 Avatar of @natusaspire Avatar of @naveen521kk Avatar of @nemesis545 Avatar of @neverendingqs Avatar of @ngocdang499 Avatar of @ngraef Avatar of @nickylogan Avatar of @nikitastupin Avatar of @notzheng Avatar of @ntjandra Avatar of @nurpabuccu Avatar of @obrientimothya Avatar of @okremer84 Avatar of @olefriis Avatar of @oneturkmen Avatar of @orhanar Avatar of @paraschetal Avatar of @paulbreen-symphonytalent Avatar of @paulj Avatar of @pcy190 Avatar of @pedro-javierf Avatar of @pedrodapp Avatar of @peterspbr Avatar of @phosfox Avatar of @pmccauley1994 Avatar of @prashantkatiyar9777 Avatar of @prathamesh-88 Avatar of @pre7et Avatar of @prksu Avatar of @proxi Avatar of @purna1sai Avatar of @rahulsinghinfosec Avatar of @raichuAK Avatar of @ralacher Avatar of @ramshazar Avatar of @rcowsill Avatar of @redawl Avatar of @redtux Avatar of @rizalgowandy Avatar of @robisetiapermadi Avatar of @rockarts Avatar of @rojan-rijal Avatar of @romainmenke Avatar of @rose-m Avatar of @ryan-rozario Avatar of @s850042002 Avatar of @sakshamgurbhele Avatar of @samgiz Avatar of @samuelrojasm Avatar of @saurav3199 Avatar of @scalatar Avatar of @scottwn Avatar of @seano-vs Avatar of @sebader Avatar of @shamo0 Avatar of @shiyandong Avatar of @shripadpaturkar Avatar of @skbasava Avatar of @smajchrz Avatar of @sms-system Avatar of @sodsec Avatar of @srkgupta Avatar of @ssupdoc Avatar of @stonejiajia Avatar of @sukolenvo Avatar of @supersat Avatar of @suresh Avatar of @swapshivam3 Avatar of @syedalirazaidi Avatar of @teekenl Avatar of @tejasmorkar Avatar of @timoles Avatar of @tonghuikang Avatar of @trburgess Avatar of @tutasla Avatar of @tyage Avatar of @upitroma Avatar of @vedant3620 Avatar of @veera83372 Avatar of @vicktory22 Avatar of @vinamramunot-tech Avatar of @vitallium Avatar of @vmwsree Avatar of @wei Avatar of @weitongttt Avatar of @willdurand Avatar of @xleepy Avatar of @xmunoz BABas Alberts

Written by
GitHub

Writes for GitHub. Part of the Tech Report engineering index.

All articles by Bas Alberts →