CVE-2026-10796
Description
nvm versions 0.40.4 and earlier can execute arbitrary commands from a malicious Node.js mirror's version strings.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
nvm versions 0.40.4 and earlier can execute arbitrary commands from a malicious Node.js mirror's version strings.
Vulnerability
Node Version Manager (nvm) versions up to and including 0.40.4 are vulnerable to arbitrary command execution. This occurs when nvm processes version strings obtained from a configured Node.js/io.js mirror. Specifically, the nvm_download() function uses eval to construct download commands, and nvm_get_checksum() interpolates version strings into an awk program. If an attacker controls the configured mirror, or can intercept traffic to a non-TLS mirror, they can supply malicious version strings that exploit these sinks [1]. The default mirror (https://nodejs.org over TLS) is not affected.
Exploitation
An attacker must control the configured Node.js/io.js mirror, supply mirror content to a user or CI system using a non-default mirror, or perform a man-in-the-middle attack on a non-TLS mirror. The attacker crafts a version string within the mirror's index.tab file. When a user runs a command like nvm install, nvm fetches this index and uses the malicious version string. In nvm_download(), a version string containing command substitution, such as $(id), is executed by the local shell via eval. In nvm_get_checksum(), a crafted version string can break out of the awk program and execute arbitrary commands using awk's system() function [1].
Impact
Successful exploitation allows an attacker to run arbitrary shell commands with the privileges of the user running nvm. This can lead to a full compromise of the affected system or environment, depending on the privileges granted to the user executing nvm [1].
Mitigation
This vulnerability was fixed in nvm by modifying nvm_download() to avoid eval and pass arguments as literal argv elements [2], and by changing nvm_get_checksum() to pass version data to awk via -v instead of interpolating it into the program string [4]. Additionally, nvm now rejects version strings containing disallowed characters before they are used in URLs or commands [3]. The fixed version is not yet tagged, but the changes are available in the master branch. No workarounds are specified beyond updating to a patched version when available.
- nvm executes commands from a malicious mirror's version strings (eval in nvm_download, awk in nvm_get_checksum)
- [Fix] `nvm_download`: avoid `eval` so mirror-supplied version strings… · nvm-sh/nvm@6d870d1
- [Fix] `nvm_download_artifact`: reject version strings with disallowed… · nvm-sh/nvm@70fb4ed
- [Fix] `nvm_get_checksum`: pass the tarball name to awk as data, not p… · nvm-sh/nvm@90bb887
AI Insight generated on Jun 4, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
390bb88748ba6[Fix] `nvm_get_checksum`: pass the tarball name to awk as data, not program text
2 files changed · +29 −1
nvm.sh+1 −1 modified@@ -1927,7 +1927,7 @@ nvm_get_checksum() { SHASUMS_URL="${MIRROR}/${3}/SHASUMS.txt" fi - nvm_download -L -s "${SHASUMS_URL}" -o - | command awk "{ if (\"${4}.${5}\" == \$2) print \$1}" + nvm_download -L -s "${SHASUMS_URL}" -o - | command awk -v tarball="${4}.${5}" '{ if (tarball == $2) print $1 }' } nvm_print_versions() {
test/fast/Unit tests/nvm_get_checksum awk injection+28 −0 added@@ -0,0 +1,28 @@ +#!/bin/sh + +WORK="${TMPDIR:-/tmp}/nvm_get_checksum_awk.$$" +PROOF="${WORK}/PWNED" + +cleanup () { + unset -f die cleanup nvm_download + rm -rf "${WORK}" +} +die () { echo "$@" ; cleanup ; exit 1; } + +\. ../../../nvm.sh + +mkdir -p "${WORK}" + +# GHSA-3c52-35h2-gfmm: nvm_get_checksum must treat the (untrusted, version-derived) +# slug as awk data, never as awk program text. +# given a crafted slug carrying an unconditional awk system() action +# and a mock that supplies one SHASUMS record (so such an action would fire) +nvm_download () { printf 'deadbeef sometarball\n'; } +# when nvm_get_checksum runs with that slug as its 4th argument +rm -f "${PROOF}" +nvm_get_checksum node std v1 'x" == $2) print $1} {system("touch${IFS}'"$PROOF"'")} #' tar.gz >/dev/null 2>&1 +# then the injected awk code must not execute +[ ! -e "${PROOF}" ] || die 'awk injection fires in nvm_get_checksum (slug interpolated into awk program text)' + +cleanup +echo 'nvm_get_checksum awk injection: passed'
70fb4ede6b97[Fix] `nvm_download_artifact`: reject version strings with disallowed characters
2 files changed · +56 −4
nvm.sh+10 −4 modified@@ -2506,10 +2506,16 @@ nvm_download_artifact() { local VERSION VERSION="${4}" - if [ -z "${VERSION}" ]; then - nvm_err 'A version number is required.' - return 3 - fi + case "${VERSION}" in + '') + nvm_err 'A version number is required.' + return 3 + ;; + *[!0-9A-Za-z._+-]*) + nvm_err 'Invalid version: contains disallowed characters' + return 3 + ;; + esac if [ "${KIND}" = 'binary' ] && ! nvm_binary_available "${VERSION}"; then nvm_err "No precompiled binary available for ${VERSION}."
test/fast/Unit tests/nvm_download_artifact version injection+46 −0 added@@ -0,0 +1,46 @@ +#!/bin/sh + +WORK="${TMPDIR:-/tmp}/nvm_version_injection.$$" +PROOF="${WORK}/PWNED" + +cleanup () { + unset -f die cleanup nvm_download + rm -rf "${WORK}" +} +die () { echo "$@" ; cleanup ; exit 1; } + +\. ../../../nvm.sh + +mkdir -p "${WORK}" +export NVM_DIR="${WORK}" + +# GHSA-3c52-35h2-gfmm: a mirror-supplied version with shell/awk metacharacters +# must be rejected before it is used in URLs, paths, or awk. Neutralize network. +nvm_download () { return 0; } + +# given a version containing command-substitution syntax +# when nvm_download_artifact is asked to download it +# then it is rejected for disallowed characters and nothing is executed +rm -f "${PROOF}" +out="$(nvm_download_artifact node source std 'v1$(touch '"${PROOF}"')' 2>&1 </dev/null)" +case "${out}" in + *'disallowed characters'*) : ;; + *) die "command-substitution version was not rejected; got: ${out}" ;; +esac +[ ! -e "${PROOF}" ] || die 'command-substitution payload executed via nvm_download_artifact' + +# and an awk-breakout version is likewise rejected +out="$(nvm_download_artifact node source std 'v1"==$2){system("x")}#' 2>&1 </dev/null)" +case "${out}" in + *'disallowed characters'*) : ;; + *) die "awk-style version was not rejected; got: ${out}" ;; +esac + +# and a legitimate nightly version must NOT be rejected by the character guard +out="$(nvm_download_artifact node source std 'v22.0.0-nightly20240101abcdef' 2>&1 </dev/null)" +case "${out}" in + *'disallowed characters'*) die 'a legitimate nightly version was wrongly rejected by the guard' ;; +esac + +cleanup +echo 'nvm_download_artifact version injection: passed'
6d870d182cd5[Fix] `nvm_download`: avoid `eval` so mirror-supplied version strings can't inject commands
2 files changed · +135 −34
nvm.sh+49 −34 modified@@ -115,47 +115,62 @@ nvm_get_latest() { nvm_echo "${NVM_LATEST_URL##*/}" } +# Every argument is passed through as a literal argv element so that untrusted, +# mirror-supplied version strings in the URLs are never re-parsed by the shell +# (which would allow command substitution / OS command injection). nvm_download() { - if nvm_has "curl"; then - local CURL_COMPRESSED_FLAG="" - local CURL_HEADER_FLAG="" - local sanitized_header - - if [ -n "${NVM_AUTH_HEADER:-}" ]; then - sanitized_header=$(nvm_sanitize_auth_header "${NVM_AUTH_HEADER}") - CURL_HEADER_FLAG="--header \"Authorization: ${sanitized_header}\"" - fi + local sanitized_header + sanitized_header='' + if [ -n "${NVM_AUTH_HEADER:-}" ]; then + sanitized_header="$(nvm_sanitize_auth_header "${NVM_AUTH_HEADER}")" + fi + local NVM_DOWNLOADER + NVM_DOWNLOADER='' + if nvm_has "curl"; then + NVM_DOWNLOADER='curl' + set -- -q --fail "$@" if nvm_curl_use_compression; then - CURL_COMPRESSED_FLAG="--compressed" + set -- --compressed "$@" fi - local NVM_DOWNLOAD_ARGS - NVM_DOWNLOAD_ARGS='' - for arg in "$@"; do - NVM_DOWNLOAD_ARGS="${NVM_DOWNLOAD_ARGS} \"$arg\"" - done - eval "curl -q --fail ${CURL_COMPRESSED_FLAG:-} ${CURL_HEADER_FLAG:-} ${NVM_DOWNLOAD_ARGS}" elif nvm_has "wget"; then + NVM_DOWNLOADER='wget' # Emulate curl with wget - ARGS=$(nvm_echo "$@" | command sed " - s/--progress-bar /--progress=bar / - s/--compressed // - s/--fail // - s/-L // - s/-I /--server-response / - s/-s /-q / - s/-sS /-nv / - s/-o /-O / - s/-C - /-c / - ") - - if [ -n "${NVM_AUTH_HEADER:-}" ]; then - sanitized_header=$(nvm_sanitize_auth_header "${NVM_AUTH_HEADER}") - ARGS="${ARGS} --header \"Authorization: ${sanitized_header}\"" - fi - # shellcheck disable=SC2086 - eval wget $ARGS + local NVM_DOWNLOAD_WGET_COUNT + NVM_DOWNLOAD_WGET_COUNT=$# + local NVM_DOWNLOAD_WGET_SKIP + NVM_DOWNLOAD_WGET_SKIP=0 + local NVM_DOWNLOAD_WGET_ARG + for NVM_DOWNLOAD_WGET_ARG in "$@"; do + if [ "${NVM_DOWNLOAD_WGET_SKIP}" = '1' ]; then + NVM_DOWNLOAD_WGET_SKIP=0 + continue + fi + case "${NVM_DOWNLOAD_WGET_ARG}" in + '--progress-bar') set -- "$@" '--progress=bar' ;; + '--compressed') : ;; + '--fail') : ;; + '-L') : ;; + '-I') set -- "$@" '--server-response' ;; + '-s') set -- "$@" '-q' ;; + '-sS') set -- "$@" '-nv' ;; + '-o') set -- "$@" '-O' ;; + '-C') NVM_DOWNLOAD_WGET_SKIP=1; set -- "$@" '-c' ;; + *) set -- "$@" "${NVM_DOWNLOAD_WGET_ARG}" ;; + esac + done + shift "${NVM_DOWNLOAD_WGET_COUNT}" fi + + if [ -z "${NVM_DOWNLOADER}" ]; then + return 0 + fi + + if [ -n "${NVM_AUTH_HEADER:-}" ]; then + set -- "$@" --header "Authorization: ${sanitized_header}" + fi + + "${NVM_DOWNLOADER}" "$@" } nvm_sanitize_auth_header() {
test/fast/Unit tests/nvm_download no eval injection+86 −0 added@@ -0,0 +1,86 @@ +#!/bin/sh + +OLDPATH="$PATH" +WORK="$PWD/nvm_download-noeval-work.$$" +TEST_BIN="$WORK/bin" +ARGV_LOG="$WORK/argv.log" +PROOF="$WORK/nvm_injection_proof" + +cleanup() { + unset -f die cleanup + rm -rf "$WORK" + export PATH="$OLDPATH" +} +die () { echo "$@" ; cleanup ; exit 1; } + +\. ../../../nvm.sh + +OLDPATH="$PATH" + +mkdir -p "$TEST_BIN" + +# fake curl/wget: record each received argument verbatim, then succeed +{ + echo '#!/bin/sh' + echo ': > "$ARGV_LOG"' + echo 'for a in "$@"; do printf "%s\n" "$a" >> "$ARGV_LOG"; done' + echo 'exit 0' +} > "$TEST_BIN/curl" +chmod +x "$TEST_BIN/curl" +cp "$TEST_BIN/curl" "$TEST_BIN/wget" + +export ARGV_LOG +export PATH="$TEST_BIN:$OLDPATH" + +# given a url containing command-substitution syntax (mirror-supplied) +INJECT_URL="http://example.test/v1\$(touch ${PROOF})/x.tar.gz" + +# when nvm_download is invoked (curl path) +rm -f "$PROOF" +nvm_download "$INJECT_URL" -o - || die 'nvm_download (curl) returned nonzero on injection url' +# then the substitution must not have executed +[ ! -e "$PROOF" ] || die "command injection fired via curl path: proof file was created" +# and curl must have received the url as one literal argument +grep -Fxq "$INJECT_URL" "$ARGV_LOG" || die "curl did not receive the url as a single literal argument; got: $(cat "$ARGV_LOG")" + +# given a normal download (curl path) +URL="https://nodejs.org/dist/index.tab" +FILE="$WORK/target" +# when invoked with -L -s URL -o FILE +nvm_download -L -s "$URL" -o "$FILE" || die 'nvm_download (curl) returned nonzero on normal url' +# then the url, -o, and output file are passed through verbatim +grep -Fxq "$URL" "$ARGV_LOG" || die "curl did not receive the url; got: $(cat "$ARGV_LOG")" +grep -Fxqe "-o" "$ARGV_LOG" || die "curl did not receive -o; got: $(cat "$ARGV_LOG")" +grep -Fxq "$FILE" "$ARGV_LOG" || die "curl did not receive the output file; got: $(cat "$ARGV_LOG")" +# and curl-style flags are passed through verbatim (curl keeps -s; it is not translated to wget's -q) +grep -Fxqe "-s" "$ARGV_LOG" || die "curl did not receive -s verbatim; got: $(cat "$ARGV_LOG")" +rm -f "$FILE" + +# given curl is unavailable (the wget-path calls run with PATH limited to our +# fake wget, so neither the fake nor the system curl is found) +rm -f "$TEST_BIN/curl" + +# when nvm_download is invoked with the injection url (wget path) +rm -f "$PROOF" +( PATH="$TEST_BIN"; export PATH; nvm_download "$INJECT_URL" -o - ) || die 'nvm_download (wget) returned nonzero on injection url' +# then the substitution must not have executed +[ ! -e "$PROOF" ] || die "command injection fired via wget path: proof file was created" +grep -Fxq "$INJECT_URL" "$ARGV_LOG" || die "wget did not receive the url as a single literal argument; got: $(cat "$ARGV_LOG")" + +# when invoked with -L -C - --progress-bar URL -o FILE (wget path) +( PATH="$TEST_BIN"; export PATH; nvm_download -L -C - --progress-bar "$URL" -o "$FILE" ) || die 'nvm_download (wget) returned nonzero on normal url' +# then flags are translated to wget equivalents +grep -Fxqe "-c" "$ARGV_LOG" || die "wget did not translate -C - to -c; got: $(cat "$ARGV_LOG")" +grep -Fxqe "--progress=bar" "$ARGV_LOG" || die "wget did not translate --progress-bar; got: $(cat "$ARGV_LOG")" +grep -Fxqe "-O" "$ARGV_LOG" || die "wget did not translate -o to -O; got: $(cat "$ARGV_LOG")" +grep -Fxqe "-L" "$ARGV_LOG" && die "wget should drop -L; got: $(cat "$ARGV_LOG")" +grep -Fxqe "-C" "$ARGV_LOG" && die "wget should not pass -C through; got: $(cat "$ARGV_LOG")" +grep -Fxqe "-" "$ARGV_LOG" && die "wget should drop the lone - after -C; got: $(cat "$ARGV_LOG")" + +# when invoked with -s (wget path) +( PATH="$TEST_BIN"; export PATH; nvm_download -L -s "$URL" -o "$FILE" ) || die 'nvm_download (wget) returned nonzero on -s url' +# then -s becomes -q +grep -Fxqe "-q" "$ARGV_LOG" || die "wget did not translate -s to -q; got: $(cat "$ARGV_LOG")" + +cleanup +echo "nvm_download no eval injection: passed"
Vulnerability mechanics
Root cause
"The version strings returned by a configured Node.js/io.js mirror were not properly sanitized before being used in shell commands or awk programs."
Attack vector
An attacker can control a Node.js/io.js mirror, supply mirror content to a user on a non-default mirror, or perform a man-in-the-middle attack on a non-TLS mirror [ref_id=1]. When a user runs a command like `nvm install`, the tool fetches version information from the configured mirror. A crafted version string in the mirror's `index.tab` file can then be executed as arbitrary shell commands or via awk's `system()` function [ref_id=1]. The default mirror over TLS is not affected.
Affected code
The vulnerability exists in the `nvm_download()` function, which uses `eval` to execute download commands, and in the `nvm_get_checksum()` function, which interpolates version strings into an `awk` program. Both functions process version strings obtained from a configured mirror's `index.tab` file without sufficient sanitization [ref_id=1]. The `nvm_download_artifact()` function is also involved in processing these version strings before they are used in URLs or file paths [ref_id=3].
What the fix does
The patches address the vulnerability by preventing the interpolation of untrusted version strings into executable contexts. Specifically, `nvm_download` now passes arguments as literal elements to `eval` instead of building a string, and `nvm_get_checksum` passes the version slug to `awk` as data using the `-v` option rather than interpolating it into the program text [patch_id=4827865, patch_id=4827866]. Additionally, a check is implemented to reject any version string that does not conform to the Node.js/io.js version grammar before it is used [patch_id=4827864].
Preconditions
- configThe `NVM_NODEJS_ORG_MIRROR` or `NVM_IOJS_ORG_MIRROR` environment variable is configured to point to an untrusted or HTTP mirror.
- networkThe user is susceptible to a man-in-the-middle attack on a non-TLS mirror.
- inputThe attacker controls the content of the `index.tab` file served by the configured mirror.
Reproduction
The following reproduction steps demonstrate the `eval` sink vulnerability: 1. Save the provided fake curl script to a temporary directory and ensure it is first in the system's PATH. 2. Remove any existing proof file (`/tmp/pwned_nvm`). 3. Set the `NVM_DIR` environment variable to a temporary directory and export `NVM_NODEJS_ORG_MIRROR` to an attacker-controlled HTTP URL. 4. Source the `nvm.sh` script and run `nvm install node`. 5. Check if the proof file (`/tmp/pwned_nvm`) was created, indicating successful command execution.
The `awk` sink can be triggered by a crafted version string in the `index.tab` file, causing `nvm_get_checksum()` to execute an injected `awk system()` call during `nvm install` [ref_id=1].
Generated on Jun 4, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/nvm-sh/nvm/commit/6d870d182cd5333647ffa16c0d7dbcd817ec27a8nvdPatch
- github.com/nvm-sh/nvm/commit/70fb4ede6b9731d75d86451d48caa5faffbec21cnvdPatch
- github.com/nvm-sh/nvm/commit/90bb88748ba6c29c2cec73b18ed7057413aef308nvdPatch
- github.com/nvm-sh/nvm/security/advisories/GHSA-3c52-35h2-gfmmnvdExploitMitigationPatchVendor Advisory
News mentions
0No linked articles in our index yet.