Init. Commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<h1 align="center">Runner Ashfall</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Forensic-blueviolet" alt="Forensic"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
В `runner_ashfall_case.zip` собраны следы инцидента в CI/CD: комментарий к issue, действия агента, установка npm-пакета, облачная авторизация и деплой. Первые девять ответов восстанавливаем по журналам. Из них получаем ключ к финальной заметке с десятым флагом.
|
||||
|
||||
## Решение
|
||||
|
||||
Распаковываем архив. Полезные артефакты удобно разделить по участкам цепочки:
|
||||
|
||||
| Каталог | Содержимое |
|
||||
|---|---|
|
||||
| `github/`, `agent/` | Issue, вызовы инструментов, CI run и attestation |
|
||||
| `repo/`, `npm/` | Lockfile до и после, индекс кеша и копия `preinstall.js` |
|
||||
| `runner/` | События процессов и файлов |
|
||||
| `cloud/` | OIDC-токены, CloudTrail и закрывающая заметка |
|
||||
| `registry/`, `kubernetes/` | События образов и фактическое обновление deployment |
|
||||
|
||||
### Комментарий и запуск CI (Q01–Q03)
|
||||
|
||||
В `github/audit_log.jsonl` ищем `issue_comment.created` с меткой `release-triage` и объектом `agent_route`. Его `source_key` — `issue-1842-comment-7f31`, первый ответ. Похожий комментарий с меткой `docs` дальше в эту цепочку не попадает.
|
||||
|
||||
В `agent/triage_assistant.jsonl` ищем событие `tool_call`, у которого `input.from_issue` совпадает с найденным `source_key`. У вызова `deploy_plan.render` читаем `tool_call_id`, равный `tool.deploy_plan.render:6d91c0`. Это второй ответ.
|
||||
|
||||
В `github/actions/` ищем запуск, у которого `source.tool_call_id` совпадает с найденным вызовом. Подходит `run_8716443.json`, из которого берём `tuple = run-8716443/check-314159265`. С найденными идентификаторами запуска сверяем остальные события.
|
||||
|
||||
### Пакет, кеш и раннер (Q04–Q06)
|
||||
|
||||
Сравниваем `repo/package-lock.before.json` и `repo/package-lock.after.json`. В новом lockfile выделяется зависимость с `hasInstallScript = true`: `@northstar/ledger-seal@2.8.4-ash.3`. Флаг `hasInstallScript` указывает на скрипт установки. Его имя проверяем по событиям раннера.
|
||||
|
||||
В `npm/_cacache/index-v5/ledger-seal-entry.json` сверяем имя и версию пакета, затем читаем `cache_path`. Получаем путь к содержимому кеша — ответ Q05.
|
||||
|
||||
По этому пути ищем процесс в `runner/process_events.jsonl`. У него должны совпасть `run_id=8716443` и `check_run_id=314159265`, а команда содержит `node ... preinstall.js`. Поле `host` даёт `ghr-prod-x64-ephemeral-0c8a7d`, ответ Q06. Другой ephemeral-раннер относится к старому запуску и сюда не подходит.
|
||||
|
||||
### Облачные права и деплой (Q07–Q09)
|
||||
|
||||
Из `jobs[0].job_id` в JSON запуска берём `9317`. В `cloud/oidc_tokens.jsonl` выбираем токен по тройке run/check/job и сохраняем его `sub` целиком. Так получаем Q07 и связываем облачную авторизацию с конкретной работой CI.
|
||||
|
||||
В `cloud/cloudtrail.jsonl` находим `PutObject`, у которого `x-amz-meta-run-id` и `x-amz-meta-check-run-id` совпадают с исследуемым запуском. `responseElements.object_locator` содержит bucket, key и версию объекта — это Q08.
|
||||
|
||||
Остаётся выяснить, что действительно развернули. В `kubernetes/audit.jsonl` ищем `patch` deployment `staging/recon-ledger`. Аннотация `northstar.io/source-object` должна точно совпасть с Q08. Из `requestObject.spec.template.spec.containers[0].image` берём полный адрес образа с digest, ответ Q09.
|
||||
|
||||
> Digest в attestation описывает результат сборки до подмены артефакта. Для ответа о деплое нужен образ из Kubernetes patch, связанный с конкретной версией объекта.
|
||||
|
||||
### Финальная заметка (Q10)
|
||||
|
||||
`cloud/final_closeout.note` содержит `nonce`, `ciphertext` и `mac`. `nonce` и `ciphertext` переводим из hex в байты, `mac` оставляем hex-строкой. Ответы Q01–Q09 записываем строками в список `raw_answers` **без** оболочки `caplag{...}`, сохраняя порядок. Для ключа соединяем их разделителем `|`.
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
key = hashlib.sha256("|".join(raw_answers).encode()).digest()
|
||||
expected_mac = hashlib.sha256(key + nonce + ciphertext).hexdigest()
|
||||
assert expected_mac == mac
|
||||
```
|
||||
|
||||
Сначала сверяем `expected_mac` с `mac`: ошибка хотя бы в одном ответе даст несовпадение. Затем получаем seed потока и его блоки:
|
||||
|
||||
```python
|
||||
seed = hashlib.sha256(key + nonce).digest()
|
||||
stream = bytearray()
|
||||
counter = 0
|
||||
while len(stream) < len(ciphertext):
|
||||
stream.extend(hashlib.sha256(seed + counter.to_bytes(4, "big")).digest())
|
||||
counter += 1
|
||||
plain = bytes(a ^ b for a, b in zip(ciphertext, stream))
|
||||
```
|
||||
|
||||
Счётчик начинается с нуля. `zip` ограничивает XOR длиной шифротекста. Декодируем `plain` как UTF-8 и разбираем JSON, поле `final_flag` содержит последний ответ.
|
||||
|
||||
Получилась сквозная цепочка событий. Текст issue запустил инструмент агента, тот создал CI run, установка зависимости привела к действиям на раннере, а связанные облачные события — к подменённому деплою. Точные идентификаторы на каждом переходе позволяют отделить её от ложных следов.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Все этапы
|
||||
|
||||
| Этап | Флаг |
|
||||
|---|---|
|
||||
| 1 | `caplag{issue-1842-comment-7f31}` |
|
||||
| 2 | `caplag{tool.deploy_plan.render:6d91c0}` |
|
||||
| 3 | `caplag{run-8716443/check-314159265}` |
|
||||
| 4 | `caplag{@northstar/ledger-seal@2.8.4-ash.3}` |
|
||||
| 5 | `caplag{sha512/6a/2d/6a2dc1e9b4f0d776a8e2b8fd47025703e617b1d9a61b88d5ad7b2c9f2e6a10f4}` |
|
||||
| 6 | `caplag{ghr-prod-x64-ephemeral-0c8a7d}` |
|
||||
| 7 | `caplag{repo:northstar-clearing/recon-ledger:ref:refs/heads/staging:workflow:release.yml:job_id:9317}` |
|
||||
| 8 | `caplag{s3://nstar-stage-artifacts/releases/recon-ledger/2026.05.14/recon-ledger.tgz#v7d4b19e2}` |
|
||||
| 9 | `caplag{registry.local/northstar/recon-ledger@sha256:9c2fd7f0d2b541e0b44267cc8d59a7cf508b1e3a7e422bcf1ac8a2d1377f1cc4}` |
|
||||
| 10 | `caplag{runner_ashfall_ci_blast_radius}` |
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def read_text(zf: zipfile.ZipFile, name: str) -> str:
|
||||
return zf.read(name).decode("utf-8")
|
||||
|
||||
|
||||
def read_json(zf: zipfile.ZipFile, name: str) -> Any:
|
||||
return json.loads(read_text(zf, name))
|
||||
|
||||
|
||||
def read_jsonl(zf: zipfile.ZipFile, name: str) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for line in read_text(zf, name).splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
rows.append(json.loads(line))
|
||||
return rows
|
||||
|
||||
|
||||
def derive_key(raw_answers: list[str]) -> bytes:
|
||||
return hashlib.sha256("|".join(raw_answers).encode("utf-8")).digest()
|
||||
|
||||
|
||||
def xor_stream(key: bytes, length: int) -> bytes:
|
||||
stream = bytearray()
|
||||
counter = 0
|
||||
while len(stream) < length:
|
||||
stream.extend(hashlib.sha256(key + counter.to_bytes(4, "big")).digest())
|
||||
counter += 1
|
||||
return bytes(stream[:length])
|
||||
|
||||
|
||||
def decrypt_closeout(note: bytes, raw_answers: list[str]) -> dict[str, Any]:
|
||||
wrapped = json.loads(note.decode("utf-8"))
|
||||
key = derive_key(raw_answers)
|
||||
nonce = bytes.fromhex(wrapped["nonce"])
|
||||
ciphertext = bytes.fromhex(wrapped["ciphertext"])
|
||||
expected_mac = hashlib.sha256(key + nonce + ciphertext).hexdigest()
|
||||
if wrapped["mac"] != expected_mac:
|
||||
raise ValueError("closeout note MAC mismatch; one of Q01-Q09 is wrong")
|
||||
stream = xor_stream(hashlib.sha256(key + nonce).digest(), len(ciphertext))
|
||||
plaintext = bytes(a ^ b for a, b in zip(ciphertext, stream))
|
||||
return json.loads(plaintext.decode("utf-8"))
|
||||
|
||||
|
||||
def wrap_answer(value: str) -> str:
|
||||
if value.startswith("caplag{"):
|
||||
return value
|
||||
return f"caplag{{{value}}}"
|
||||
|
||||
|
||||
def recover_raw_answers(zf: zipfile.ZipFile) -> list[str]:
|
||||
raw: list[str] = []
|
||||
|
||||
audit = read_jsonl(zf, "github/audit_log.jsonl")
|
||||
issue_event = next(
|
||||
row
|
||||
for row in audit
|
||||
if row.get("event") == "issue_comment.created"
|
||||
and "release-triage" in row.get("labels", [])
|
||||
and "agent_route" in row
|
||||
)
|
||||
raw.append(issue_event["agent_route"]["source_key"])
|
||||
|
||||
agent = read_jsonl(zf, "agent/triage_assistant.jsonl")
|
||||
tool_call = next(
|
||||
row
|
||||
for row in agent
|
||||
if row.get("event") == "tool_call"
|
||||
and row.get("tool") == "deploy_plan.render"
|
||||
and row.get("input", {}).get("from_issue") == raw[0]
|
||||
)
|
||||
raw.append(tool_call["tool_call_id"])
|
||||
|
||||
run = read_json(zf, "github/actions/run_8716443.json")
|
||||
if run["source"]["tool_call_id"] != raw[1]:
|
||||
raise ValueError("run source does not match recovered tool call")
|
||||
raw.append(run["tuple"])
|
||||
|
||||
lockfile = read_json(zf, "repo/package-lock.after.json")
|
||||
suspicious_pkg = None
|
||||
for package_path, package_info in lockfile["packages"].items():
|
||||
if not package_path.startswith("node_modules/"):
|
||||
continue
|
||||
if package_info.get("hasInstallScript"):
|
||||
package_name = package_path.removeprefix("node_modules/")
|
||||
suspicious_pkg = f"{package_name}@{package_info['version']}"
|
||||
break
|
||||
if suspicious_pkg is None:
|
||||
raise ValueError("no install-script package found")
|
||||
raw.append(suspicious_pkg)
|
||||
|
||||
npm_entry = read_json(zf, "npm/_cacache/index-v5/ledger-seal-entry.json")
|
||||
if f"{npm_entry['package']}@{npm_entry['version']}" != raw[3]:
|
||||
raise ValueError("npm cache entry does not match lockfile package")
|
||||
raw.append(npm_entry["cache_path"])
|
||||
|
||||
runner_events = read_jsonl(zf, "runner/process_events.jsonl")
|
||||
run_id = int(raw[2].split("/", 1)[0].removeprefix("run-"))
|
||||
check_run_id = int(raw[2].split("/", 1)[1].removeprefix("check-"))
|
||||
runner_event = next(
|
||||
row
|
||||
for row in runner_events
|
||||
if row.get("run_id") == run_id
|
||||
and row.get("check_run_id") == check_run_id
|
||||
and raw[4] in row.get("cmd", "")
|
||||
)
|
||||
raw.append(runner_event["host"])
|
||||
|
||||
oidc_tokens = read_jsonl(zf, "cloud/oidc_tokens.jsonl")
|
||||
job_id = run["jobs"][0]["job_id"]
|
||||
oidc = next(
|
||||
row
|
||||
for row in oidc_tokens
|
||||
if row.get("run_id") == run_id
|
||||
and row.get("check_run_id") == check_run_id
|
||||
and row.get("job_id") == job_id
|
||||
)
|
||||
raw.append(oidc["sub"])
|
||||
|
||||
cloudtrail = read_jsonl(zf, "cloud/cloudtrail.jsonl")
|
||||
put_object = next(
|
||||
row
|
||||
for row in cloudtrail
|
||||
if row.get("eventName") == "PutObject"
|
||||
and row.get("requestParameters", {}).get("x-amz-meta-run-id") == str(run_id)
|
||||
and row.get("requestParameters", {}).get("x-amz-meta-check-run-id") == str(check_run_id)
|
||||
)
|
||||
raw.append(put_object["responseElements"]["object_locator"])
|
||||
|
||||
kube_audit = read_jsonl(zf, "kubernetes/audit.jsonl")
|
||||
deploy_patch = next(
|
||||
row
|
||||
for row in kube_audit
|
||||
if row.get("verb") == "patch"
|
||||
and row.get("objectRef", {}).get("resource") == "deployments"
|
||||
and row.get("objectRef", {}).get("name") == "recon-ledger"
|
||||
and row.get("requestObject", {})
|
||||
.get("metadata", {})
|
||||
.get("annotations", {})
|
||||
.get("northstar.io/source-object")
|
||||
== raw[7]
|
||||
)
|
||||
image = deploy_patch["requestObject"]["spec"]["template"]["spec"]["containers"][0]["image"]
|
||||
raw.append(image)
|
||||
|
||||
return raw
|
||||
|
||||
|
||||
def solve_bundle(bundle_path: str | Path) -> list[str]:
|
||||
with zipfile.ZipFile(bundle_path) as zf:
|
||||
raw = recover_raw_answers(zf)
|
||||
closeout = decrypt_closeout(zf.read("cloud/final_closeout.note"), raw)
|
||||
return [wrap_answer(value) for value in raw] + [wrap_answer(closeout["final_flag"])]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if len(argv) != 2:
|
||||
print(f"usage: {Path(argv[0]).name} public/runner_ashfall_case.zip", file=sys.stderr)
|
||||
return 2
|
||||
answers = solve_bundle(argv[1])
|
||||
for idx, answer in enumerate(answers, 1):
|
||||
print(f"Q{idx:02d}: {answer}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv))
|
||||
Reference in New Issue
Block a user