Skip to content

Zero-length MultiIdentity yields vacuously-true Verify() — anyone-can-spend output #1858

Description

@adecaro

Summary

A multisig-typed owner identity with zero constituent identities can be placed on a token output.
The resulting Verifier has zero sub-verifiers; its Verify() length-equality guard passes when
both sides are zero, the loop body never runs, and the function returns nil. Anyone can then
spend that output by supplying an empty MultiSignature{}, with no real signature required.

Severity: Low
Component: token/services/identity/multisig
Affected files: sig.go, deserializer.go


Root Cause

1. sig.go — vacuously-true Verify()

// sig.go:67 (before fix)
if len(v.Verifiers) != len(sig.Signatures) {  // 0 != 0 → false, guard passes
    return errors.Errorf(...)
}
for k, ver := range v.Verifiers {  // iterates zero times
    ...
}
return nil  // ← returns success with no signature checked

When len(v.Verifiers) == 0 and the attacker supplies MultiSignature{Signatures: []}, the
length check 0 != 0 is false, the loop body never executes, and Verify returns nil.

2. deserializer.go — no rejection of empty MultiIdentity at the trust boundary

// deserializer.go:119 (before fix)
verifier.Verifiers = make([]driver.Verifier, len(multisigIdentity.Identities))
// len == 0 → empty slice, returned without error
return verifier, nil

DeserializeVerifier builds verifier.Verifiers from multisigIdentity.Identities without
checking whether that slice is non-empty, producing a zero-verifier Verifier.

3. WrapIdentities guard is constructor-side only

identity.go:47 has a len(ids) == 0 guard, but this is only reached via the honest-client
constructor. An attacker can directly asn1.Marshal a MultiIdentity{Identities: []}, wrap it
in a TypedIdentity{Type: Multisig}, and submit it on-chain — bypassing the constructor
entirely. The on-chain transfer validators check only that the owner byte slice is non-empty,
not its content.


Exploit Scenario

  1. Attacker constructs a transfer output whose Owner bytes encode
    TypedIdentity{Type: Multisig, Identity: asn1(MultiIdentity{Identities: []})}.
  2. The output is committed on-chain (validators only require len(owner) != 0).
  3. Anyone later submits a transfer spending that output with the signature slot set to
    asn1(MultiSignature{Signatures: []}).
  4. HasBeenSignedBy draws that signature, calls multisig.Verifier.Verify, which returns
    nil. The spend is authorized with no real signature.

The attacker can only do this to outputs they themselves create (not direct theft from others),
but it creates tokens spendable by anyone — a deliberate "anyone-can-spend" output that
may interact badly with auditor holding-limit logic or be used to launder provenance.


Preconditions

  • The multisig identity type is registered in the deserializer multiplex (it is, in both shipped drivers).
  • The attacker can author a transfer output's Owner bytes (any sender can).

Proposed Fix

Two guards, both required:

sig.go — fail closed on degenerate verifier state (defence-in-depth):

func (v *Verifier) Verify(msg, raw []byte) error {
    if len(v.Verifiers) == 0 {
        return errors.New("multisig verifier has no members")
    }
    ...
}

deserializer.go — reject at the trust boundary:

if len(multisigIdentity.Identities) == 0 {
    return nil, errors.New("multisig identity has no members")
}

References

  • token/services/identity/multisig/sig.go
  • token/services/identity/multisig/deserializer.go
  • token/services/identity/multisig/identity.go

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Fields

No fields configured for Bug.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions