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
- Attacker constructs a transfer output whose
Owner bytes encode
TypedIdentity{Type: Multisig, Identity: asn1(MultiIdentity{Identities: []})}.
- The output is committed on-chain (validators only require
len(owner) != 0).
- Anyone later submits a transfer spending that output with the signature slot set to
asn1(MultiSignature{Signatures: []}).
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
Summary
A multisig-typed owner identity with zero constituent identities can be placed on a token output.
The resulting
Verifierhas zero sub-verifiers; itsVerify()length-equality guard passes whenboth sides are zero, the loop body never runs, and the function returns
nil. Anyone can thenspend that output by supplying an empty
MultiSignature{}, with no real signature required.Severity: Low
Component:
token/services/identity/multisigAffected files:
sig.go,deserializer.goRoot Cause
1.
sig.go— vacuously-trueVerify()When
len(v.Verifiers) == 0and the attacker suppliesMultiSignature{Signatures: []}, thelength check
0 != 0isfalse, the loop body never executes, andVerifyreturnsnil.2.
deserializer.go— no rejection of emptyMultiIdentityat the trust boundaryDeserializeVerifierbuildsverifier.VerifiersfrommultisigIdentity.Identitieswithoutchecking whether that slice is non-empty, producing a zero-verifier
Verifier.3.
WrapIdentitiesguard is constructor-side onlyidentity.go:47has alen(ids) == 0guard, but this is only reached via the honest-clientconstructor. An attacker can directly
asn1.MarshalaMultiIdentity{Identities: []}, wrap itin a
TypedIdentity{Type: Multisig}, and submit it on-chain — bypassing the constructorentirely. The on-chain transfer validators check only that the owner byte slice is non-empty,
not its content.
Exploit Scenario
Ownerbytes encodeTypedIdentity{Type: Multisig, Identity: asn1(MultiIdentity{Identities: []})}.len(owner) != 0).asn1(MultiSignature{Signatures: []}).HasBeenSignedBydraws that signature, callsmultisig.Verifier.Verify, which returnsnil. 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
Ownerbytes (any sender can).Proposed Fix
Two guards, both required:
sig.go— fail closed on degenerate verifier state (defence-in-depth):deserializer.go— reject at the trust boundary:References
token/services/identity/multisig/sig.gotoken/services/identity/multisig/deserializer.gotoken/services/identity/multisig/identity.go