Знакомая схема защиты от подмены. Сервер кладёт клиенту cookie user=guest&role=reader (обычно, конечно, посложнее конструкции, но эту возьмём для примера) и рядом — подпись, чтобы клиент не переписал reader на admin и не пришёл обратно уже администратором. Подпись считается так:
sig = sha256( secret + "user=guest&role=reader" )
Секрет знает только сервер. Клиент видит и данные, и подпись, но секрета у него нет — значит, пересчитать подпись под изменённые данные он вроде бы не может. На ревью это проходит без вопросов: SHA-256 — стойкая функция, что тут поломается?
Поломается не сама SHA-256, а конструкция вокруг неё. Подпись такого вида подделывается, не зная секрета вообще: к данным можно дописать в конец &role=admin и предъявить к нему валидную подпись. Не подобрать секрет, не сломать SHA-256 — именно дописать. Атака называется length extension, и сейчас мы её проделаем на своём же сервере.
Самодельная подпись, которая кажется надёжной
Пусть сервер подписывает параметры так (секрет super-secret-key-42 знает только он):
message = b'user=guest&role=reader' sig = sha256(secret + message).hexdigest()
Клиент получает и message, и sig — они лежат, например, в подписанной cookie или в параметрах ссылки. Перехватить их может кто угодно, кому этот запрос виден. Секрета среди них нет.
message = user=guest&role=reader sig = 91599ee999b8864b616ec6e1e1b9ecee18d4d89be5364e43485769a53eb740aa
Задача атакующего — превратить role=reader в role=admin так, чтобы сервер принял. Подпись пересчитать он не может: секрета нет. Казалось бы, тупик.
Зацепка: цифра подписи — это внутреннее состояние хеша
Выход из тупика прячется в том, как SHA-256 устроена внутри. Она, как SHA-1 и MD5, построена по схеме Меркла — Дамгарда. Сообщение дополняется до кратности блоку и прогоняется блок за блоком; каждый блок обновляет внутреннее 256-битное состояние, и это состояние после последнего блока — и есть то, что вы видите как «цифру подписи». Хеш не хранит ничего сверх этих 256 бит.
Ключевое следствие: если у вас на руках sha256(secret + message), у вас на руках готовое внутреннее состояние функции ровно после того, как она обработала secret + message и добавленное ею дополнение. Восстановить это состояние из подписи — просто разобрать 64 hex-символа обратно в восемь 32-битных слов. А дальше можно поставить SHA-256 в это состояние, сообщить ей, сколько байт уже «внутри», и продолжить хешировать — дописать в конец произвольные данные вроде &role=admin. На выходе — валидная подпись для secret + message + дополнение + &role=admin, и секрет для этого не понадобился.
Считаем дополнение руками
Чтобы это не выглядело фокусом, посчитаем всё в байтах на нашем примере. Секрет super-secret-key-42 — 19 байт, сообщение user=guest&role=reader — 22 байта. Сервер склеивает их и хеширует: на вход SHA-256 приходит 41 байт.
SHA-256 работает блоками по 64 байта и перед хешированием дополняет вход до кратности 64. Это дополнение — замыкающая последовательность байт, которую функция добавляет сама, — устроено всегда одинаково: один байт \x80, потом нули, потом 8 байт с длиной исходных данных в битах. Считаем:
данные: 41 байт (secret 19 + message 22) + \x80: 42 байта + нули до 56: 56 байт (14 нулевых байт) + длина (8 байт): 64 байта ← ровно один блок длина в битах = 41 * 8 = 328 = 0x148 поле длины (8 байт, big-endian): 00 00 00 00 00 00 01 48 последний байт 0x48 — это символ 'H'
Итого secret + message + дополнение — это ровно 64 байта, один блок. А дайджест sha256(secret + message) — это состояние SHA-256 после обработки ровно этого блока: в нём уже «свёрнут» весь первый блок вместе с секретом. Чтобы продолжить хеширование со второго блока, знать сам секрет не нужно — достаточно этого состояния. Оно у нас есть — это и есть подпись.
Сервер, проверяя подделку, склеит secret со всем присланным (message + то же дополнение + &role=admin), добавит своё дополнение и посчитает хеш — и получит ровно то, что предъявил атакующий, потому что первые 64 байта у обоих совпадают байт в байт.
Собираем подделку и предъявляем её
Теперь у нас есть всё, и в подписи не остаётся ничего волшебного. Вся атака держится на одном свойстве SHA-256: её готовая подпись — это не запечатанный результат, а просто восемь внутренних 32-битных регистров функции (H0..H7), выписанных подряд в шестнадцатеричном виде. Одно 32-битное слово — это 8 hex-символов. Восемь слов — 64 символа, ровно длина подписи. Поэтому «разобрать подпись обратно в состояние» — это буквально нарезать её строку на восемь кусков по 8 символов, слева направо:
подпись — 64 hex-символа: 9 1 5 9 9 e e 9 9 9 b 8 8 6 4 b 6 1 6 e c 6 e 1 e 1 b 9 e c e e ... └── 8 симв. ──┘ └── 8 симв. ──┘ └── 8 симв. ──┘ └── 8 симв. ──┘ нарезаем по 8 символов — каждый кусок и есть одно слово состояния: символы 1..8 → H0 = 91599ee9 символы 9..16 → H1 = 99b8864b символы 17..24 → H2 = 616ec6e1 символы 25..32 → H3 = e1b9ecee символы 33..40 → H4 = 18d4d89b символы 41..48 → H5 = e5364e43 символы 49..56 → H6 = 485769a5 символы 57..64 → H7 = 3eb740aa (8 hex-символов = 4 байта = одно 32-битное слово; склей H0..H7 подряд — получишь исходную подпись)
Дальше ставим SHA-256 ровно в это состояние H0..H7, объявляем «первые 64 байта уже позади» и дохешиваем &role=admin. На выходе — готовая подделанная подпись:
forged_sig = 6cfd01b40f888830151ad3f4067189f9d559b2ebbc2cba9b6cd78dcf4ef168af
Слово «дохешиваем» стоит расшифровать — в нём вся операция. SHA-256 идёт по данным блоками по 64 байта и прогоняет каждый блок через функцию сжатия: та берёт восемь слов текущего состояния и очередной блок, а выдаёт новые восемь слов. Первый блок — secret + message + дополнение, ровно 64 байта — сервер уже прогнал, и его результат и есть перехваченная подпись, наши H0..H7. Остаётся собрать и прогнать второй блок: в нём наши данные и финальное дополнение, посчитанное уже для полной длины forged-сообщения (secret 19 + message 22 + дополнение 23 + &role=admin 11 = 75 байт):
второй блок, 64 байта: &role=admin 11 байт — наши данные \x80 стартовый байт дополнения \x00 × 44 нули 00 00 00 00 00 00 02 58 длина всего сообщения: 75 байт = 600 бит = 0x258
Один раз прогоняем этот блок через функцию сжатия, стартуя из H0..H7, — и восемь слов на выходе и есть forged_sig. Одно применение функции сжатия, и подпись готова; секрет в этот блок не входит.
Проверить это можно и руками. Ниже — всё дохешивание целиком, на стандартной библиотеке, из перехваченной подписи и без секрета. Основной объём тут — обычная функция сжатия SHA-256 (её берут готовой); сама атака — это три коротких шага в самом низу. Запустите — на выходе ровно тот же forged_sig:
import struct # функция сжатия SHA-256: 8 слов состояния + блок 64 байта -> новые 8 слов. # K — 64 константы раундов SHA-256: фиксированные числа из стандарта, одинаковые у всех. # Это старшие 32 бита дробных частей кубических корней первых 64 простых чисел (2, 3, 5, ..., 311); # выводить их не нужно — они просто перемешивают биты на каждом из 64 раундов сжатия. K = [0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2] rr = lambda x, n: ((x >> n) | (x << (32 - n))) & 0xffffffff def compress(state, block): w = list(struct.unpack('>16L', block)) for i in range(16, 64): s0 = rr(w[i-15],7) ^ rr(w[i-15],18) ^ (w[i-15] >> 3) s1 = rr(w[i-2],17) ^ rr(w[i-2],19) ^ (w[i-2] >> 10) w.append((w[i-16] + s0 + w[i-7] + s1) & 0xffffffff) a,b,c,d,e,f,g,h = state for i in range(64): t1 = (h + (rr(e,6)^rr(e,11)^rr(e,25)) + ((e&f)^(~e&g)) + K[i] + w[i]) & 0xffffffff t2 = ((rr(a,2)^rr(a,13)^rr(a,22)) + ((a&b)^(a&c)^(b&c))) & 0xffffffff h,g,f,e,d,c,b,a = g,f,e,(d+t1)&0xffffffff,c,b,a,(t1+t2)&0xffffffff return [(x+y) & 0xffffffff for x,y in zip(state, [a,b,c,d,e,f,g,h])] sig = '91599ee999b8864b616ec6e1e1b9ecee18d4d89be5364e43485769a53eb740aa' # перехвачено append = b'&role=admin' first_block = 64 # secret+message+дополнение = один блок = 64 байта # 1) перехваченную подпись разбираем в 8 слов состояния H0..H7: state = list(struct.unpack('>8L', bytes.fromhex(sig))) # 2) собираем второй блок: хвост + дополнение для полной длины (64 + 11 = 75 байт): total = first_block + len(append) block2 = append + b'\x80' block2 += b'\x00' * ((56 - len(block2)) % 64) + struct.pack('>Q', total * 8) # 3) одно применение функции сжатия из состояния H0..H7: forged = ''.join('%08x' % x for x in compress(state, block2)) print(forged) # 6cfd01b40f888830151ad3f4067189f9d559b2ebbc2cba9b6cd78dcf4ef168af
forged_sig собрана всего из двух вещей — перехваченной подписи 91599ee9… (её мы разложили в состояние) и дописанного &role=admin. Секрета среди них нет, и он ни разу не понадобился.
Осталось предъявить эту подпись серверу. Сервер честно пересчитывает sha256(secret + подделанное_сообщение) со своим секретом — и мы сверяем его результат с нашим:
forged_sig = 6cfd01b40f888830151ad3f4067189f9d559b2ebbc2cba9b6cd78dcf4ef168af ← собрали мы, без секрета real_sig = 6cfd01b40f888830151ad3f4067189f9d559b2ebbc2cba9b6cd78dcf4ef168af ← посчитал сервер, с секретом ПОДДЕЛКА ПРИНЯТА: True
Совпало. forged_sig мы построили, продолжив чужую подпись, — а сервер получил ровно её же, честно посчитав со своим секретом. Секрет так и остался только у сервера: мы его не подбирали и не видели. Подпись оказалась не замком на данных, а недописанной страницей, которую любой желающий продолжает с того места, где её оставили.
Длину ключа знать не нужно
В примере длину secret мы взяли как данность — 19 байт. В реальной атаке её не знают, но и не обязаны. Без длины не собрать дополнение первого блока — зато длин немного, и их перебирают: под каждую предполагаемую длину строим свою подделку и отдаём серверу на проверку. Настоящая длина — та, для которой сервер примет подпись:
# для каждой длины строим подделку и отдаём серверу: длина secret = 16: сервер отверг длина secret = 17: сервер отверг длина secret = 18: сервер отверг длина secret = 19: сервер ПРИНЯЛ подпись длина secret = 20: сервер отверг
Сервер принял подпись ровно на длине 19 — это и есть длина super-secret-key-42. Заранее её знать не требовалось: хватило перебора и ответов «принял / отверг».
В чём подвох: сырое дополнение внутри сообщения
Честности ради — у атаки есть цена, и её видно, если посмотреть на подделанное сообщение целиком:
b'user=guest&role=reader\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01H&role=admin'
Между исходными данными и дописанной частью сидит то самое дополнение SHA-256 — байт \x80, нули и длина. Атакующий не может её убрать: без неё подпись не сойдётся. Значит, приём срабатывает там, где принимающая сторона к этому мусору терпима: разбирает параметры по принципу «последнее значение побеждает» (а role в конце как раз последний), принимает лишние байты в значении, не спотыкается о \x00. Для строгого парсера, который отвергнет двоичный мусор в середине, подделка развалится на разборе — и это один из немногих смягчающих факторов, а не основание расслабиться.
Что защищает: HMAC — и почему он устроен именно так
Правильная подпись на секрете — это HMAC, а не самодельная склейка. Тот же приём против HMAC не проходит: если сервер считает hmac.new(secret, data, sha256), то наша расширенная подпись и то, что реально считает сервер, — два разных значения:
# сервер считает HMAC, а не sha256(secret + data): наша расширенная подпись: b9bc98a253ea75f084ffb8ef… HMAC сервера на том же сообщении: ef77e5840d8a78f2964bcb25… совпало: False
Причина — в устройстве HMAC. Он считает не H(secret + message), а H((secret⊕opad) + H((secret⊕ipad) + message)) — хеш от секрета, приклеенного к другому хешу от секрета же. Наружу выходит результат внешнего хеширования, а его внутреннее состояние начинается с блока, замешанного на секрете, которого у атакующего нет. Продолжить дохеширование неоткуда — конструкция специально сделана нерасширяемой. Отсюда практическое правило: для подписи на общем секрете берут hmac.new(key, msg, sha256), а не sha256(key + msg).
Есть и второй путь — хеш, не подверженный расширению по построению: SHA-3 (губка, а не конструкция Меркла — Дамгарда) и BLAKE2 (с финализацией, из-за которой финальное состояние не является продолжаемым). Но если инфраструктура уже на SHA-2, менять алгоритм ради этого не нужно — достаточно перестать склеивать руками и звать HMAC.
Что посмотреть у себя
Поискать в коде саму конструкцию. grep по sha256(, sha1(, md5( в связке со словами secret, key, sign, token: интересует любое место, где секрет и данные склеиваются и подаются в хеш одной строкой. hashlib.sha256(key + msg), hashlib.sha256(secret + payload).hexdigest() — это оно.
Проверить самописные подписанные cookie и токены. Самое частое место — «мы сами подписываем сессию/ссылку/webhook». Если подпись считается склейкой, а не HMAC, её чинят заменой на hmac, и заодно сравнивают подписи через hmac.compare_digest, а не обычным ==.
Проверить приёмную сторону на терпимость к мусору. Строгий парсер и отказ от дублей параметров — не замена HMAC, но лишний рубеж: он ломает подделку на разборе дополнения.
Коротко
sha256(secret + data) выглядит как подпись, но подписью не является: SHA-2 и её предки отдают наружу своё внутреннее состояние, а из него любой желающий продолжает хеширование и дописывает в конец данных что угодно с валидной подписью — не зная ни секрета, ни его длины. Лечится это не сменой алгоритма и не удлинением ключа, а HMAC, который и придуман ровно затем, чтобы наружу не торчало продолжаемое состояние. Если где-то в коде секрет с данными склеиваются в один sha256() — это не подпись, это приглашение.

