#1732·opennhp

TEE Remote Attestation Fallback Bypass

Author: geo-chenCreated Sep 14, 2026Updated Sep 14, 2026
Labelsbug

reported on 3 August 2026 via https://github.com/OpenNHP/opennhp/security/advisories/GHSA-v4q6-jq9w-f2xx

Summary

OpenNHP's server-side TEE (Trusted Execution Environment) remote attestation check can be completely bypassed by any party who can submit a DHP_KNK ("DHP knock") message to the server. The dispatcher that decides whether to run AMD/Hygon CSV hardware attestation, or to unconditionally succeed, is driven entirely by whether the attacker-supplied evidence JSON contains a test_purpose key - there is no build tag, environment check, or configuration flag gating this fallback path to non-production builds. In addition, the fallback verifier's GetMeasure()/GetSerialNumber() methods return each other's underlying fields (a field swap), which callers must compensate for to land on a specific enrolled device entry, but which does not otherwise change the impact.

The only thing standing between an attacker and a passing "Verified" attestation result is knowledge of an already-enrolled (measure, serial_number) pair. Those values come from a plaintext TOML allowlist file on the server (tee.toml) that ships with example values in the project's own repository, and are not runtime secrets, cryptographic material, or anything requiring proof of hardware possession to learn.

Details

nhp/core/verifier/verifier.go:

go
type Verifier interface {
    Verify() error
    GetSerialNumber() string
    GetMeasure() string
}

type FallbackVerifier struct {
    TestPurpose  string `json:"test_purpose"`
    Measure      string `json:"measure"`
    SerialNumber string `json:"serial_number"`
}

func (f *FallbackVerifier) Verify() error {
    return nil
}

func (f *FallbackVerifier) GetSerialNumber() string {
    return f.Measure
}

func (f *FallbackVerifier) GetMeasure() string {
    return f.SerialNumber
}

func NewVerifier(compressedEvienceBase64 string) (Verifier, error) {
    compressedEvidence, _ := base64.StdEncoding.DecodeString(compressedEvienceBase64)
    r, _ := zlib.NewReader(bytes.NewReader(compressedEvidence))
    evidenceBytes, _ := io.ReadAll(r)
    var evidence map[string]any
    json.Unmarshal(evidenceBytes, &evidence)

    var verifier Verifier
    if _, ok := evidence["test_purpose"]; ok {
        verifier, err = NewFallbackVerifier(evidenceBytes)
    } else {
        verifier, err = csv.NewAttestation(string(evidenceBytes))
    }
    return verifier, err
}

The choice between "run hardware attestation" (csv.NewAttestation, which validates an actual AMD/Hygon confidential-computing report) and "always succeed" (FallbackVerifier.Verify() unconditionally returns nil) is made purely by inspecting attacker-controlled JSON for a test_purpose key. There is no separate code path, build constraint, or config flag that restricts FallbackVerifier to test/CI builds; it ships live in the same binary an NHP server runs in production.

Confirmed by live testing that the getters are also swapped: GetMeasure() returns the JSON serial_number field, and GetSerialNumber() returns the JSON measure field.

The server-side call site, endpoints/server/config.go:

go
func (s *UdpServer) AppraiseEvidence(evidenceBase64 string) bool {
    var measure string
    var sn string

    attestationVerifier, err := verifier.NewVerifier(evidenceBase64)
    if err != nil {
        return false
    }
    if err := attestationVerifier.Verify(); err != nil {
        return false
    }

    measure = attestationVerifier.GetMeasure()
    sn = attestationVerifier.GetSerialNumber()

    s.teeMapMutex.Lock()
    defer s.teeMapMutex.Unlock()

    if _, exist := s.teeMap[measure]; exist {
        s.teeMap[measure].Verified = true
        return s.teeMap[measure].SerialNumber == sn
    }
    return false
}

s.teeMap is populated at server startup by updateTee(), which reads a plaintext TOML config file listing enrolled devices:

go
func (s *UdpServer) updateTee(file string) (err error) {
    content, err := os.ReadFile(file)
    var tees TeeAttestationReports
    teeMap := make(map[string]*TeeAttestationReport)
    toml.Unmarshal(content, &tees)
    for _, tee := range tees.TEEs {
        teeMap[tee.Measure] = tee
    }
    s.teeMap = teeMap
    return err
}

The project's own shipped example config (endpoints/server/main/etc/tee.toml, also present under docker/nhp-server/etc/tee.toml) shows the format:

toml
[[TEEs]]
Measure = "19178a674248bbca705863bbf75ecaa049fcf3dfcc5ff59a80dcc5cbb60dae59"
SerialNumber = "TMEX300023050201"

AppraiseEvidence() is called directly from endpoints/server/nhpauth.go HandleKnockRequest, which processes every incoming DHP_KNK NHP packet from a connecting agent:

go
if ppd.HeaderType == core.DHP_KNK { // dhp knock
    if s.AppraiseEvidence(dhpKnkMsg.Evidence) {
        dhpAckMsg.ErrCode = common.ErrSuccess.ErrorCode()
    } else {
        dhpAckMsg.ErrCode = common.ErrEvidenceAppraisalFailed.ErrorCode()
    }
    return
}

A true return from AppraiseEvidence() is the sole gate between a successful DHP-knock acknowledgement and a rejected one. Evidence in common.DHPKnockMsg is a plain string field populated by whatever the connecting client sends; nothing about it is bound to a proof of hardware possession, only to the content of the JSON blob itself.

PoC

(available upon request)

Impact

Any party able to send a DHP_KNK NHP packet to an OpenNHP server, who also knows one enrolled (measure, serial_number) pair from that server's tee.toml, can obtain a passing attestation result (ErrSuccess ack, teeMap[measure].Verified = true) without possessing the corresponding confidential-computing hardware, without producing a AMD/Hygon CSV attestation report, and without the csv.NewAttestation() verification path ever executing. This completely defeats the purpose of the TEE attestation feature: it can no longer be used to distinguish a genuine, unmodified confidential-computing device from an attacker who merely knows that device's (non-secret, config-file) identifier pair.

The (measure, serial_number) values are not cryptographic secrets or proof of hardware possession. measure is a software/firmware measurement hash (deterministic from known, often publicly documented firmware/kernel images) and serial_number is a device identifier stored in a plaintext allowlist file on the server; the project's own shipped example configuration demonstrates the format with real-looking values checked into the public repository. Any operational leak of this file, or reuse of its example values, or disclosure of a device's serial number through any other channel, is sufficient for an attacker to defeat attestation for that device.

In this v1.0.0 release, the confirmed, code-verified consequence is the attestation decision itself (the DHP_KNK ack outcome and the persisted Verified flag). No other code path in this release was found to read teeMap[x].Verified; the concrete "grant network access" logic used by the standard NHP_KNK knock flow (handler.AuthWithNHP, which opens ipset/eBPF firewall rules) is a separate code path not invoked by DHP_KNK. This finding is reported as a broken authentication / protection-mechanism failure of the attestation feature itself: the security guarantee OpenNHP documents (a TEE-hardware-backed remote attestation gate) is not actually enforced, and any policy or future feature that trusts a Verified state, or a successful DHP_KNK ack, as evidence of genuine confidential-computing hardware is misled.

version: 1.0.0