Description

nps is an intranet tunneling proxy server (about 34k stars) that exposes forwarded services on public ports and is managed through a web UI. Its development stopped in 2021, but the tool is still widely deployed. Three defects share one theme: every secret that protects the proxy is recoverable.

The first is the comparison itself. Basic credentials are checked with plain string equality, lib/common/util.go:71:

pair := strings.SplitN(string(b), ":", 2)
if len(pair) != 2 {
	return false
}
return pair[0] == user && pair[1] == passwd

CheckAuth guards the HTTP proxy endpoints that nps publishes to the internet (server/proxy/base.go:67, called per host at server/proxy/http.go:153). The web management login compares with the same operator, for the administrator account (web/controllers/login.go:61) and for every client web account (web/controllers/login.go:82), and the tunnel key lookup compares digests the same way (lib/file/db.go:127). Go string equality returns at the first differing word, so all of these are timing oracles on secret material.

The second is the tunnel key design. A secret tunnel is addressed by a 32-byte key that is the unsalted MD5 of the tunnel password (lib/crypt/crypt.go:59):

//Generate 32-bit MD5 strings
func Md5(s string) string {
	h := md5.New()
	h.Write([]byte(s))
	return hex.EncodeToString(h.Sum(nil))
}

Visitors send those 32 bytes on the bridge connection and the server accepts them by matching crypt.Md5(stored) == received (lib/file/db.go:127, reached from bridge/bridge.go:282 for p2p and server/server.go:72 for secret tunnels). The digest is therefore the credential itself: capturing one handshake yields a working key, and MD5 does nothing to slow down an offline dictionary attack on a human-chosen password.

The third is storage. The JSON database serializes tunnels, hosts and clients with json.Marshal (lib/file/file.go:164, :170 and :176), and the struct fields carry no json:"-" tags: VerifyKey (lib/file/obj.go:37), WebPassword (obj.go:50) and Tunnel.Password (obj.go:137) all land in the data files in cleartext. A tunnel even embeds its whole client object, so tasks.json duplicates the client's key and web password as well.

Reproduction

Secrets on disk can be shown with the project's own store. This in-package test registers one client and one secret tunnel, writes the data files, and reads them back:

package file

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
)

func TestPocSecretsOnDisk(t *testing.T) {
	dir := t.TempDir()
	if err := os.MkdirAll(filepath.Join(dir, "conf"), 0o700); err != nil {
		t.Fatal(err)
	}
	db := NewJsonDb(dir)

	client := NewClient("sup3r-s3cret-vkey", false, false)
	client.Id = 1
	client.WebUserName = "admin"
	client.WebPassword = "Tr0ub4dor-3"
	db.Clients.Store(client.Id, client)
	db.StoreClientsToJsonFile()

	tunnel := &Tunnel{Id: 1, Port: 8022, Mode: "secret",
		Password: "tunnel-pass-123", Client: client}
	db.Tasks.Store(tunnel.Id, tunnel)
	db.StoreTasksToJsonFile()

	clientsJson, _ := os.ReadFile(db.ClientFilePath)
	tasksJson, _ := os.ReadFile(db.TaskFilePath)
	t.Logf("clients.json: %s", clientsJson)
	t.Logf("tasks.json: %s", tasksJson)

	for _, secret := range []string{"sup3r-s3cret-vkey",
		"Tr0ub4dor-3", "tunnel-pass-123"} {
		if !strings.Contains(string(clientsJson), secret) &&
			!strings.Contains(string(tasksJson), secret) {
			t.Fatalf("secret %q not found in data files", secret)
		}
	}
}

Observed output, secrets verbatim in the data files (long records wrapped):

--- PASS: TestPocSecretsOnDisk (0.00s)
    poc_test.go:36: clients.json: {"Cnf":{...},"Id":1,
        "VerifyKey":"sup3r-s3cret-vkey",...,"WebUserName":"admin",
        "WebPassword":"Tr0ub4dor-3",...}
    poc_test.go:37: tasks.json: {"Id":1,"Port":8022,"Mode":"secret",
        "Client":{...,"VerifyKey":"sup3r-s3cret-vkey",...,
        "WebPassword":"Tr0ub4dor-3",...},"Password":"tunnel-pass-123",...}

The timing behavior was measured the same way, comparing a stored key against guesses with different common prefixes (50 million rounds each):

raw ==  common prefix  0: 3.0 ns/compare
raw ==  common prefix  8: 3.0 ns/compare
raw ==  common prefix 16: 3.0 ns/compare
raw ==  common prefix 24: 4.0 ns/compare
raw ==  common prefix 31: 4.0 ns/compare
raw ==  full match      : 2.0 ns/compare

Go's runtime compares in word-sized chunks, so the leak is coarse: the mismatch position inside the final 8-byte word is visible as roughly a nanosecond, not per byte. On the real lookup path the compare sits inside about 280 ns of map iteration and hashing per call, so a remote attacker needs heavy averaging over many requests to lift the signal out of jitter. The measurement says: a defect that is real and measurable, not a free remote password recovery. The fix costs one line, which is why it should still be made.

Impact

Cleartext storage of sensitive information (CWE-312) is the main exposure. Anyone who can read the data directory, a backup of it, or exfiltrate the files through any other file-read flaw recovers, with no cracking at all:

  • every tunnel password, which also gates visitor access to forwarded services;
  • every web account password, including the administrator's web login;
  • every client verify key, which is the credential npc clients use on the control connection. A leaked verify key lets the attacker impersonate the client, register tunnels, and route traffic through the victim's machine.

The unsalted MD5 key scheme (CWE-916) weakens the same boundary from the network side: the 32 bytes that open a secret tunnel are a plain MD5 digest, so a captured handshake needs no password, and a guessed password can be confirmed offline at full MD5 speed. The timing oracles (CWE-208) add a slow side channel on the same credentials. The primary chain needs local or backup access to the data files, which is why the overall rating is medium rather than high.

As supporting context for the same material: newly generated verify keys come from crypt.GetRandomString(16) (lib/file/db.go:214), which seeds math/rand with the wall-clock nanosecond (lib/crypt/crypt.go:69), so even the "random" keys carry no more entropy than a timestamp guess.

Solution

  • Compare secrets in constant time with crypto/subtle.ConstantTimeCompare at all three sites: CheckAuth, the web login handlers, and the MD5 lookup in GetTaskByMd5Password. Return zero on length mismatch first.
  • Stop writing secrets to disk. Either tag the three fields with json:"-" and keep the secrets in a separate store with strict permissions, or at minimum restrict the data directory to the service user (mode 0700) and treat every copy of conf/*.json as a full credential disclosure.
  • Replace the MD5-of-password scheme with a random 128-bit key per tunnel, generated with crypto/rand rather than the timestamp-seeded generator, so that a captured key says nothing about any password and cannot be brute forced.

Until the project moves again (it has been dormant since October 2021), operators should assume the data files are passwords and protect them accordingly: restrict the directory, exclude it from world-readable backups, and rotate every tunnel password and verify key if a copy has ever left the host.

Timeline

  • 2026-09-24: Reported publicly as ehang-io/nps#1339, found while scanning popular repositories with my static analyzer (ghoul). The repository has had no commit since 2021-10-09 and no release since v0.26.10, so no private disclosure attempt seemed useful.

References