Init. Commit

This commit is contained in:
Caplag
2026-09-17 00:50:07 +03:00
commit 880d8698d6
115 changed files with 7611 additions and 0 deletions
@@ -0,0 +1,45 @@
<h1 align="center">Grant Residue</h1>
<p align="center">
<img src="https://img.shields.io/badge/category-Forensic-blueviolet" alt="Forensic"/>
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
</p>
В архиве `grant_residue_case.zip` находится выгрузка SOC. В материалах инцидента описаны отправка кода финансовому отделу под предлогом передачи встречи и последующий экспорт Microsoft Graph. Пароль не менялся, MFA прошла штатно. По журналам определяем, какой поток авторизации использовался для доступа.
## Решение
Начинаем с рабочей станции, затем идём по облачным журналам:
| Файл | Что ищем |
|---|---|
| `endpoint/powershell_transcript.txt` | Код, который видела жертва |
| `endpoint/defender_device_events.jsonl` | Подтверждение того же кода |
| `entra/signin_logs.jsonl` | Успешный вход и `correlationId` |
| `entra/audit_logs.jsonl` | Выданный OAuth-грант |
| `m365/unified_audit_log.jsonl` | Экспорт с этим грантом |
В транскрипте PowerShell находим `FJQ9-L2RM` — строку формата `[A-Z0-9]{4}-[A-Z0-9]{4}`. Тот же код есть в телеметрии Defender, в `AdditionalFields.UserCodeObserved`.
Теперь фильтруем входы Entra: `authenticationProtocol == "DeviceCode"`, `userCodeEvidence == "FJQ9-L2RM"`, `status.errorCode == 0`. Получаем успешную авторизацию через device code flow и её `correlationId`.
Пароль не менялся, поскольку жертва прошла штатный вход, но подтвердила код чужого запроса. Теперь выясняем, какие права получила эта авторизация.
В `entra/audit_logs.jsonl` находим `Consent to application` с тем же `correlationId` и `result == "success"`. Среди `targetResources[].modifiedProperties[]` читаем `newValue` свойства `OAuth2PermissionGrant.Id`. Сохраняем полный идентификатор для сопоставления событий, а его хвост после последнего дефиса, `grant72`, используем в ответе.
Проверяем последствия в M365. Нужна запись `GraphExportJobCreated`, где одновременно совпадают `CorrelationId` и `OAuth2PermissionGrantId`, а `ResultStatus == "Succeeded"`. Из `ExportId` берём хвост `export18`.
Формат ответа собирается так:
```python
parts = ["dc", "FJQ9-L2RM".replace("-", "").lower(), "grant72", "export18"]
flag = "caplag{" + "_".join(parts) + "}"
```
`dc` обозначает найденный поток. Остальные части связывают наблюдение кода на рабочей станции с успешным входом, выдачей согласия и последующим экспортом. Совпадение идентификаторов на каждом переходе подтверждает эту связь.
[Солвер](solve/solve.py).
## Флаг
`caplag{dc_fjq9l2rm_grant72_export18}`
@@ -0,0 +1,116 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import re
import sys
import zipfile
from pathlib import Path
from typing import Any
USER_CODE_RE = re.compile(r"\b[A-Z0-9]{4}-[A-Z0-9]{4}\b")
def read_text(zf: zipfile.ZipFile, name: str) -> str:
return zf.read(name).decode("utf-8")
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 normalize_code(value: str) -> str:
return re.sub(r"[^A-Za-z0-9]", "", value).lower()
def nested_modified_property(row: dict[str, Any], display_name: str) -> str | None:
for resource in row.get("targetResources", []):
for prop in resource.get("modifiedProperties", []):
if prop.get("displayName") == display_name:
return prop.get("newValue")
return None
def recover_components(bundle_path: str | Path) -> dict[str, str]:
with zipfile.ZipFile(bundle_path) as zf:
transcript = read_text(zf, "endpoint/powershell_transcript.txt")
code_match = USER_CODE_RE.search(transcript)
if not code_match:
raise ValueError("no device user code found in endpoint transcript")
user_code = code_match.group(0)
defender_rows = read_jsonl(zf, "endpoint/defender_device_events.jsonl")
if not any(row.get("AdditionalFields", {}).get("UserCodeObserved") == user_code for row in defender_rows):
raise ValueError("endpoint telemetry does not confirm the same user code")
signin_rows = read_jsonl(zf, "entra/signin_logs.jsonl")
signin = next(
row
for row in signin_rows
if row.get("authenticationProtocol") == "DeviceCode"
and row.get("userCodeEvidence") == user_code
and row.get("status", {}).get("errorCode") == 0
)
correlation_id = signin["correlationId"]
flow = signin.get("flow_key", "dc")
audit_rows = read_jsonl(zf, "entra/audit_logs.jsonl")
consent = next(
row
for row in audit_rows
if row.get("activityDisplayName") == "Consent to application"
and row.get("correlationId") == correlation_id
and row.get("result") == "success"
)
grant_id = nested_modified_property(consent, "OAuth2PermissionGrant.Id")
if not grant_id:
raise ValueError("consent event does not include OAuth2PermissionGrant.Id")
grant_tail = grant_id.rsplit("-", 1)[-1]
m365_rows = read_jsonl(zf, "m365/unified_audit_log.jsonl")
export = next(
row
for row in m365_rows
if row.get("Operation") == "GraphExportJobCreated"
and row.get("CorrelationId") == correlation_id
and row.get("OAuth2PermissionGrantId") == grant_id
and row.get("ResultStatus") == "Succeeded"
)
export_tail = export["ExportId"].rsplit("-", 1)[-1]
return {
"flow": flow,
"user_code": normalize_code(user_code),
"grant_tail": grant_tail,
"export_tail": export_tail,
}
def solve_bundle(bundle_path: str | Path) -> str:
components = recover_components(bundle_path)
return "caplag{" + "_".join(
[
components["flow"],
components["user_code"],
components["grant_tail"],
components["export_tail"],
]
) + "}"
def main(argv: list[str]) -> int:
if len(argv) != 2:
print(f"usage: {Path(argv[0]).name} public/grant_residue_case.zip", file=sys.stderr)
return 2
print(solve_bundle(argv[1]))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))