Skip to content

Commit 43c079f

Browse files
lucasssvazgithub-actions[bot]github-advanced-security[bot]pre-commit-ci-lite[bot]
authored
fix(ota): Fix authentication when using stored MD5 hashes (#11905)
* fix(ota): Fix authentication when using stored MD5 hashes * change(tools): Push generated binaries to PR * fix(ota): Re-send invitation on failure * change(tools): Push generated binaries to PR * Potential fix for code scanning alert no. 503: Use of a broken or weak cryptographic hashing algorithm on sensitive data Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * change(tools): Push generated binaries to PR * fix message * change(tools): Push generated binaries to PR * Add errors for setPasswordHash * fix compilation error * ci(pre-commit): Apply automatic fixes * change(tools): Push generated binaries to PR * fix pre-commit errors * change(tools): Push generated binaries to PR --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 69e233f commit 43c079f

File tree

5 files changed

+199
-50
lines changed

5 files changed

+199
-50
lines changed

cores/esp32/HEXBuilder.cpp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
*/
1919

2020
#include "HEXBuilder.h"
21+
#include <ctype.h>
2122

2223
static uint8_t hex_char_to_byte(uint8_t c) {
2324
return (c >= 'a' && c <= 'f') ? (c - ((uint8_t)'a' - 0xa))
@@ -26,6 +27,19 @@ static uint8_t hex_char_to_byte(uint8_t c) {
2627
: 0x10; // unknown char is 16
2728
}
2829

30+
bool HEXBuilder::isHexString(const char *str, size_t len) {
31+
for (size_t i = 0; i < len; i++) {
32+
if (isxdigit(str[i]) == 0) {
33+
return false;
34+
}
35+
}
36+
return true;
37+
}
38+
39+
bool HEXBuilder::isHexString(String str) {
40+
return isHexString(str.c_str(), str.length());
41+
}
42+
2943
size_t HEXBuilder::hex2bytes(unsigned char *out, size_t maxlen, String &in) {
3044
return hex2bytes(out, maxlen, in.c_str());
3145
}

cores/esp32/HEXBuilder.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,8 @@ class HEXBuilder {
3232

3333
static String bytes2hex(const unsigned char *in, size_t len);
3434
static size_t bytes2hex(char *out, size_t maxlen, const unsigned char *in, size_t len);
35+
36+
static bool isHexString(const char *str, size_t len);
37+
static bool isHexString(String str);
3538
};
3639
#endif

libraries/ArduinoOTA/src/ArduinoOTA.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
#include "ArduinoOTA.h"
2020
#include "NetworkClient.h"
2121
#include "ESPmDNS.h"
22+
#include "HEXBuilder.h"
2223
#include "SHA2Builder.h"
2324
#include "PBKDF2_HMACBuilder.h"
2425
#include "Update.h"
@@ -86,6 +87,26 @@ ArduinoOTAClass &ArduinoOTAClass::setPassword(const char *password) {
8687

8788
ArduinoOTAClass &ArduinoOTAClass::setPasswordHash(const char *password) {
8889
if (_state == OTA_IDLE && password) {
90+
size_t len = strlen(password);
91+
bool is_hex = HEXBuilder::isHexString(password, len);
92+
93+
if (!is_hex) {
94+
log_e("Invalid password hash. Expected hex string (0-9, a-f, A-F).");
95+
return *this;
96+
}
97+
98+
if (len == 32) {
99+
// Warn if MD5 hash is detected (32 hex characters)
100+
log_w("MD5 password hash detected. MD5 is deprecated and insecure.");
101+
log_w("Please use setPassword() with plain text or setPasswordHash() with SHA256 hash (64 chars).");
102+
log_w("To generate SHA256: echo -n 'yourpassword' | sha256sum");
103+
} else if (len == 64) {
104+
log_i("Using SHA256 password hash.");
105+
} else {
106+
log_e("Invalid password hash length. Expected 32 (deprecated MD5) or 64 (SHA256) characters.");
107+
return *this;
108+
}
109+
89110
// Store the pre-hashed password directly
90111
_password.clear();
91112
_password = password;

tools/espota.exe

2.93 KB
Binary file not shown.

tools/espota.py

Lines changed: 161 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
# Modified since 2015-09-18 from Pascal Gollor (https://github.com/pgollor)
77
# Modified since 2015-11-09 from Hristo Gochkov (https://github.com/me-no-dev)
88
# Modified since 2016-01-03 from Matthew O'Gorman (https://githumb.com/mogorman)
9+
# Modified since 2025-09-04 from Lucas Saavedra Vaz (https://github.com/lucasssvaz)
910
#
1011
# This script will push an OTA update to the ESP
1112
# use it like:
@@ -36,6 +37,19 @@
3637
# - Incorporated exception handling to catch and handle potential errors.
3738
# - Made variable names more descriptive for better readability.
3839
# - Introduced constants for better code maintainability.
40+
#
41+
# Changes
42+
# 2025-09-04:
43+
# - Changed authentication to use PBKDF2-HMAC-SHA256 for challenge/response
44+
#
45+
# Changes
46+
# 2025-09-18:
47+
# - Fixed authentication when using old images with MD5 passwords
48+
#
49+
# Changes
50+
# 2025-10-07:
51+
# - Fixed authentication when images might use old MD5 hashes stored in the firmware
52+
3953

4054
from __future__ import print_function
4155
import socket
@@ -81,7 +95,7 @@ def update_progress(progress):
8195
sys.stderr.flush()
8296

8397

84-
def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md5_target):
98+
def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message):
8599
"""
86100
Send invitation to ESP device and get authentication challenge.
87101
Returns (success, auth_data, error_message) tuple.
@@ -107,10 +121,9 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
107121

108122
sock2.settimeout(TIMEOUT)
109123
try:
110-
if md5_target:
111-
data = sock2.recv(37).decode() # "AUTH " + 32-char MD5 nonce
112-
else:
113-
data = sock2.recv(69).decode() # "AUTH " + 64-char SHA256 nonce
124+
# Try to read up to 69 bytes for new protocol (SHA256)
125+
# If device sends less (37 bytes), it's using old MD5 protocol
126+
data = sock2.recv(69).decode()
114127
sock2.close()
115128
break
116129
except: # noqa: E722
@@ -127,34 +140,49 @@ def send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md
127140
return True, data, None
128141

129142

130-
def authenticate(remote_addr, remote_port, password, md5_target, filename, content_size, file_md5, nonce):
143+
def authenticate(
144+
remote_addr, remote_port, password, use_md5_password, use_old_protocol, filename, content_size, file_md5, nonce
145+
):
131146
"""
132-
Perform authentication with the ESP device using either MD5 or SHA256 method.
147+
Perform authentication with the ESP device.
148+
149+
Args:
150+
use_md5_password: If True, hash password with MD5 instead of SHA256
151+
use_old_protocol: If True, use old MD5 challenge/response protocol (pre-3.3.1)
152+
133153
Returns (success, error_message) tuple.
134154
"""
135155
cnonce_text = "%s%u%s%s" % (filename, content_size, file_md5, remote_addr)
136156
remote_address = (remote_addr, int(remote_port))
137157

138-
if md5_target:
158+
if use_old_protocol:
139159
# Generate client nonce (cnonce)
140160
cnonce = hashlib.md5(cnonce_text.encode()).hexdigest()
141161

142-
# MD5 challenge/response protocol (insecure, use only for compatibility with old firmwares)
143-
# 1. Hash the password with MD5 (to match ESP32 storage)
162+
# Old MD5 challenge/response protocol (pre-3.3.1)
163+
# 1. Hash the password with MD5
144164
password_hash = hashlib.md5(password.encode()).hexdigest()
145165

146166
# 2. Create challenge response
147167
challenge = "%s:%s:%s" % (password_hash, nonce, cnonce)
148168
response = hashlib.md5(challenge.encode()).hexdigest()
149169
expected_response_length = 32
150170
else:
151-
# Generate client nonce (cnonce)
171+
# Generate client nonce (cnonce) using SHA256 for new protocol
152172
cnonce = hashlib.sha256(cnonce_text.encode()).hexdigest()
153173

154-
# PBKDF2-HMAC-SHA256 challenge/response protocol
155-
# The ESP32 stores the password as SHA256 hash, so we need to hash the password first
156-
# 1. Hash the password with SHA256 (to match ESP32 storage)
157-
password_hash = hashlib.sha256(password.encode()).hexdigest()
174+
# New PBKDF2-HMAC-SHA256 challenge/response protocol (3.3.1+)
175+
# The password can be hashed with either MD5 or SHA256
176+
if use_md5_password:
177+
# Use MD5 for password hash (for devices that stored MD5 hashes)
178+
logging.warning(
179+
"Using insecure MD5 hash for password due to legacy device support. "
180+
"Please upgrade devices to ESP32 Arduino Core 3.3.1+ for improved security."
181+
)
182+
password_hash = hashlib.md5(password.encode()).hexdigest()
183+
else:
184+
# Use SHA256 for password hash (recommended)
185+
password_hash = hashlib.sha256(password.encode()).hexdigest()
158186

159187
# 2. Derive key using PBKDF2-HMAC-SHA256 with the password hash
160188
salt = nonce + ":" + cnonce
@@ -189,9 +217,9 @@ def authenticate(remote_addr, remote_port, password, md5_target, filename, conte
189217
return False, str(e)
190218

191219

192-
def serve(
220+
def serve( # noqa: C901
193221
remote_addr, local_addr, remote_port, local_port, password, md5_target, filename, command=FLASH
194-
): # noqa: C901
222+
):
195223
# Create a TCP/IP socket
196224
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
197225
server_address = (local_addr, local_port)
@@ -210,58 +238,138 @@ def serve(
210238
message = "%d %d %d %s\n" % (command, local_port, content_size, file_md5)
211239

212240
# Send invitation and get authentication challenge
213-
success, data, error = send_invitation_and_get_auth_challenge(remote_addr, remote_port, message, md5_target)
241+
success, data, error = send_invitation_and_get_auth_challenge(remote_addr, remote_port, message)
214242
if not success:
215243
logging.error(error)
216244
return 1
217245

218246
if data != "OK":
219247
if data.startswith("AUTH"):
220248
nonce = data.split()[1]
249+
nonce_length = len(nonce)
221250

222-
# Try authentication with the specified method first
223-
sys.stderr.write("Authenticating...")
224-
sys.stderr.flush()
225-
auth_success, auth_error = authenticate(
226-
remote_addr, remote_port, password, md5_target, filename, content_size, file_md5, nonce
227-
)
251+
# Detect protocol version based on nonce length:
252+
# - 32 chars = Old MD5 protocol (pre-3.3.1)
253+
# - 64 chars = New SHA256 protocol (3.3.1+)
254+
255+
if nonce_length == 32:
256+
# Scenario 1: Old device (pre-3.3.1) using MD5 protocol
257+
logging.info("Detected old MD5 protocol (pre-3.3.1)")
258+
sys.stderr.write("Authenticating (MD5 protocol)...")
259+
sys.stderr.flush()
260+
auth_success, auth_error = authenticate(
261+
remote_addr,
262+
remote_port,
263+
password,
264+
use_md5_password=True,
265+
use_old_protocol=True,
266+
filename=filename,
267+
content_size=content_size,
268+
file_md5=file_md5,
269+
nonce=nonce,
270+
)
228271

229-
if not auth_success:
230-
# If authentication failed and we're not already using MD5, try with MD5
231-
if not md5_target:
272+
if not auth_success:
232273
sys.stderr.write("FAIL\n")
233-
logging.warning("Authentication failed with SHA256, retrying with MD5: %s", auth_error)
274+
logging.error("Authentication Failed: %s", auth_error)
275+
return 1
234276

235-
# Restart the entire process with MD5 to get a fresh nonce
236-
success, data, error = send_invitation_and_get_auth_challenge(
237-
remote_addr, remote_port, message, True
277+
sys.stderr.write("OK\n")
278+
logging.warning("====================================================================")
279+
logging.warning("WARNING: Device is using old MD5 authentication protocol (pre-3.3.1)")
280+
logging.warning("Please update to ESP32 Arduino Core 3.3.1+ for improved security.")
281+
logging.warning("======================================================================")
282+
283+
elif nonce_length == 64:
284+
# New protocol (3.3.1+) - try SHA256 password first, then MD5 if it fails
285+
286+
# Scenario 2: Try SHA256 password hash first (recommended for new devices)
287+
if md5_target:
288+
# User explicitly requested MD5 password hash
289+
logging.info("Using MD5 password hash as requested")
290+
sys.stderr.write("Authenticating (SHA256 protocol with MD5 password)...")
291+
sys.stderr.flush()
292+
auth_success, auth_error = authenticate(
293+
remote_addr,
294+
remote_port,
295+
password,
296+
use_md5_password=True,
297+
use_old_protocol=False,
298+
filename=filename,
299+
content_size=content_size,
300+
file_md5=file_md5,
301+
nonce=nonce,
302+
)
303+
else:
304+
# Try SHA256 password hash first
305+
sys.stderr.write("Authenticating...")
306+
sys.stderr.flush()
307+
auth_success, auth_error = authenticate(
308+
remote_addr,
309+
remote_port,
310+
password,
311+
use_md5_password=False,
312+
use_old_protocol=False,
313+
filename=filename,
314+
content_size=content_size,
315+
file_md5=file_md5,
316+
nonce=nonce,
238317
)
239-
if not success:
240-
logging.error("Failed to re-establish connection for MD5 retry: %s", error)
241-
return 1
242318

243-
if data.startswith("AUTH"):
244-
nonce = data.split()[1]
245-
sys.stderr.write("Retrying with MD5...")
319+
# Scenario 3: If SHA256 fails, try MD5 password hash (for devices with stored MD5 passwords)
320+
if not auth_success:
321+
logging.info("SHA256 password failed, trying MD5 password hash")
322+
sys.stderr.write("Retrying with MD5 password...")
246323
sys.stderr.flush()
324+
325+
# Device is back in OTA_IDLE after auth failure, need to send new invitation
326+
success, data, error = send_invitation_and_get_auth_challenge(remote_addr, remote_port, message)
327+
if not success:
328+
sys.stderr.write("FAIL\n")
329+
logging.error("Failed to get new challenge for MD5 retry: %s", error)
330+
return 1
331+
332+
if not data.startswith("AUTH"):
333+
sys.stderr.write("FAIL\n")
334+
logging.error("Expected AUTH challenge for MD5 retry, got: %s", data)
335+
return 1
336+
337+
# Get new nonce for second attempt
338+
nonce = data.split()[1]
339+
247340
auth_success, auth_error = authenticate(
248-
remote_addr, remote_port, password, True, filename, content_size, file_md5, nonce
341+
remote_addr,
342+
remote_port,
343+
password,
344+
use_md5_password=True,
345+
use_old_protocol=False,
346+
filename=filename,
347+
content_size=content_size,
348+
file_md5=file_md5,
349+
nonce=nonce,
249350
)
250-
else:
251-
auth_success = False
252-
auth_error = "Expected AUTH challenge for MD5 retry, got: " + data
253351

254-
if not auth_success:
255-
sys.stderr.write("FAIL\n")
256-
logging.error("Authentication failed with both SHA256 and MD5: %s", auth_error)
257-
return 1
258-
else:
259-
# Already tried MD5 and it failed
352+
if auth_success:
353+
logging.warning("====================================================================")
354+
logging.warning("WARNING: Device authenticated with MD5 password hash (deprecated)")
355+
logging.warning("MD5 is cryptographically broken and should not be used.")
356+
logging.warning(
357+
"Please update your sketch to use either setPassword() or setPasswordHash()"
358+
)
359+
logging.warning(
360+
"with SHA256, then upload again to migrate to the new secure SHA256 protocol."
361+
)
362+
logging.warning("======================================================================")
363+
364+
if not auth_success:
260365
sys.stderr.write("FAIL\n")
261-
logging.error("Authentication failed: %s", auth_error)
366+
logging.error("Authentication Failed: %s", auth_error)
262367
return 1
263368

264-
sys.stderr.write("OK\n")
369+
sys.stderr.write("OK\n")
370+
else:
371+
logging.error("Invalid nonce length: %d (expected 32 or 64)", nonce_length)
372+
return 1
265373
else:
266374
logging.error("Bad Answer: %s", data)
267375
return 1
@@ -381,7 +489,10 @@ def parse_args(unparsed_args):
381489
"-m",
382490
"--md5-target",
383491
dest="md5_target",
384-
help="Target device is using MD5 checksum. This is insecure, use only for compatibility with old firmwares.",
492+
help=(
493+
"Use MD5 for password hashing (for devices with stored MD5 passwords). "
494+
"By default, SHA256 is tried first, then MD5 as fallback."
495+
),
385496
action="store_true",
386497
default=False,
387498
)

0 commit comments

Comments
 (0)