# End-to-End Audit of SecondFi's recovery flow

- **Client**: SecondFi
- **Date**: September 25, 2026
- **Tags**: Cardano, gnark, Groth16, Smart Contracts, Web Security, Haskell, Go

## Introduction

On September 14th, 2026, zkSecurity was commissioned to perform an end-to-end security audit of the recovery flow for SecondFi, which enables users to recover their funds by creating a zero-knowledge proof that they possess a master private key that derives a compromised private key, without revealing the master private key.

In particular, the audit focused on the verifier implementations, the soundness and completeness of the end-to-end recovery flow, as well as the web security of the implementation of the flow in the SecondFi recovery application.

The audit lasted two weeks with two consultants and during the audit, the team found two low-severity issues in the target repositories, which the client has acknowledged and implemented fixes for. The audit team has reviewed the changes with tests and confirmed that both issues were properly fixed.

For the sake of completeness, we also reiterate that we reinvestigated the low-severity finding in the [previous audit](https://reports.zksecurity.xyz/reports/secondfi-proof-tool/#finding-insufficient-validation-of-point) and reconfirmed that it is not practically exploitable.

### Scope

The scope of the audit included the full stack of the SecondFi recovery application in the following repositories: [`Emurgo/proof-tool`](https://github.com/Emurgo/proof-tool/tree/622d584) at commit `622d584`, [`Emurgo/recovery-claims`](https://github.com/Emurgo/recovery-claims/blob/b2d701c) at commit `b2d701c`, [`Emurgo/refund`](https://github.com/Emurgo/refund/tree/664e85b) at commit `664e85b`, [`Emurgo/refund_utxo_api`](https://github.com/Emurgo/refund_utxo_api/tree/619e49c) at commit `619e49c`, [`secondfi-refund-operator`](https://github.com/Emurgo/secondfi-refund-operator/blob/01ca9ac) at commit `01ca9ac`.

1. **Frontend and backend.** The `refund`, `recovery-claims`, `refund_utxo_api`, and `secondfi-refund-operator` repos contains the frontend and the backend of the SecondFi recovery web application.

2. **In-browser prover.** The `proof-tool` repo contains the prover that is compiled to WebAssembly and runs in the browser. It generates Groth16 proofs for the recovery flow.

3. **Go and Smart Contract Groth16 verifiers.** The `proof-tool` repo contains two verifier implementations for the Groth16 proofs and is implemented in two different languages, Go and Haskell (for Cardano smart contracts).

## Summary

On a high-level, the SecondFi recovery web application supports two different recovery flows, the whitehat/friend recovery flow and the blackhat/mixed recovery flow.

### The whitehat/friend recovery flow

This flow is for affected SecondFi users whose funds have been transferred to known whitehat/friend addresses. These funds have been attached to the Cardano smart contract in the form of UTXOs that are transferred to a user-chosen address after verifying a Groth16 proof.

<img src="/img/reports/secondfi-whitehat/whitehat-workflow.svg" alt="Whitehat/Friend Recovery Flow" style="display: block; width: 70%; height: auto; margin: 0 auto;">

### The blackhat/mixed recovery flow

This flow is similar to the whitehat/friend flow, but it is designed for affected SecondFi users whose funds have been sent to known blackhat addresses or a mixture of blackhat and whitehat addresses. The source of the funds are Secondfi operator addresses that verify Groth16 proofs using the Go verifier and transfer the funds to a user-chosen address after verification.

<img src="/img/reports/secondfi-blackhat/blackhat-workflow.svg" alt="Blackhat/Mixed Recovery Flow" style="display: block; width: 70%; height: auto; margin: 0 auto;">

## Findings

### Destination proofs do not bind datums for script payment addresses

- **Severity**: Low
- **Location**: internal/circuit/ownershipdest/circuit.go, contracts/ownership-verifier/src/Ownership/ReclaimGlobalV2.hs, apps/claim-fe/src/cardano/destinationAddressV1.ts

**Description**. The destination-bound ownership proof commits to the affected credential and the 58-byte `destination-address-v1` encoding, but not to the destination output's datum or reference script ([`circuit.go:57-70`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/internal/circuit/ownershipdest/circuit.go#L57-L70)):

```go
preimage = append(preimage, domain...)
preimage = append(preimage, credential[:]...)
preimage = append(preimage, destination[:]...)
digest := hash.Blake2b(api, uapi, preimage, 32)
```

Correspondingly, `ReclaimGlobalV2` reconstructs the destination from the output's address field alone ([`ReclaimGlobalV2.hs:376-382`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/contracts/ownership-verifier/src/Ownership/ReclaimGlobalV2.hs#L376-L382)) and checks its value and proof without inspecting the datum ([`ReclaimGlobalV2.hs:575-611`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/contracts/ownership-verifier/src/Ownership/ReclaimGlobalV2.hs#L575-L611)). A valid proof therefore remains valid if a transaction builder keeps the same destination address and value but substitutes the datum while constructing the recovery output.

This matters only for a script payment address whose spending rules use the datum to select a beneficiary or other spending authority. The refund frontend accepts enterprise addresses as destinations ([`destinationAddressV1.ts:137-145`](https://github.com/Emurgo/refund/blob/3054991dd124d90c79c60d0b3d274e90f5bf2c7c/apps/claim-fe/src/cardano/destinationAddressV1.ts#L137-L145)) and encodes a script payment credential using tag `0x02` ([`destinationAddressV1.ts:173-184`](https://github.com/Emurgo/refund/blob/3054991dd124d90c79c60d0b3d274e90f5bf2c7c/apps/claim-fe/src/cardano/destinationAddressV1.ts#L173-L184)).

A datum already attached to an existing UTxO cannot be modified. The substitution described here occurs before the recovery output is created.

**Impact**. Key payment destinations are unaffected. Exploitation requires a script payment destination whose spending authority depends on its datum, plus control over transaction construction and access to the valid ownership proof. Under these narrow conditions, changing the datum could redirect control of the recovered output.

**Recommendation**. Reject script payment credentials in the refund frontend before generating a proof or submitting a claim. This should match proof-tool's existing `assertSafeWalletAddress` check, which permits only key payment credentials ([`addresses.ts:56-64`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/apps/ownership-proof-web/lib/claim/addresses.ts#L56-L64)). Apply the same restriction in the claims backend so custom clients cannot bypass the frontend check.

**Client Response**. The client has acknowledged the finding and has implemented a fix in the `refund` repository. The audit team has reviewed the diff, retested and confirmed the fix is valid.

### Malformed Groth16 proofs can exhaust the claim Lambda memory

- **Severity**: Low
- **Location**: src/claims/create/http-lambda-handler.ts, internal/prover/prover.go, ecc/bls12-381/marshal.go

**Description**. The public `POST /claims` route is registered without an API key requirement ([`post.ts:97-112`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/cdk-infra/recovery-claims-stack/api/claims/post.ts#L97-L112)) and accepts attacker-controlled Groth16 proofs. Its API Gateway request model permits a base64 proof string of up to 4,096 characters ([`post.ts:31-37`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/cdk-infra/recovery-claims-stack/api/claims/post.ts#L31-L37)), and the Lambda validates only the base64 syntax and the same maximum length ([`http-lambda-handler.ts:28-37`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/src/claims/create/http-lambda-handler.ts#L28-L37)). Neither layer validates the proof's internal commitment count.

The backend copies the submitted proof into a verifier artifact ([`verify.ts:75-94`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/src/claims/zk/verify.ts#L75-L94)) and invokes the bundled `proof-tool verify-destination` binary ([`proof-tool-verifier.ts:49-57`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/src/claims/zk/proof-tool-verifier.ts#L49-L57)). The Go verifier passes the proof to `UnmarshalProof` ([`main.go:483-502`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/cmd/proof-tool/main.go#L483-L502)), which base64-decodes it and calls gnark's `Proof.ReadFrom` without imposing structural bounds ([`prover.go:419-428`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/internal/prover/prover.go#L419-L428)).

When `ReadFrom` reaches the commitments slice, gnark-crypto reads a four-byte unsigned commitment count and allocates both a `G1Affine` slice and a boolean slice of that attacker-selected length before checking whether the input contains the declared commitments ([`marshal.go:236-245`](https://github.com/Consensys-Incorporated/gnark-crypto/blob/0a975d747c416958f20ad4e785bcf102449a81c9/ecc/bls12-381/marshal.go#L236-L245)):

```go
sliceLen, err = dec.readUint32()
if err != nil {
    return
}
if len(*t) != int(sliceLen) || *t == nil {
    *t = make([]G1Affine, sliceLen)
}
compressed := make([]bool, sliceLen)
```

The count can therefore be as large as $2^{32}-1$, even when the submitted proof contains no commitment data. At roughly 96 bytes per BLS12-381 affine point, plus the boolean slice, the maximum value would require approximately 416 GB of slice storage. The 4,096-character input limit does not bound this allocation because the declared count occupies only four bytes.

The destination circuit expects exactly one commitment ([`gate_test.go:24-30`](https://github.com/Emurgo/proof-tool/blob/622d5846d804acf5e7207f7c96be9a7de255bc3a/internal/circuit/ownershipdest/gate_test.go#L24-L30)), but this expectation is not enforced before deserialization.

**Impact**. A small unauthenticated request can make the verifier process attempt an allocation far beyond the claim Lambda's configured 1,024 MB memory limit ([`lambdas.ts:16-17`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/cdk-infra/recovery-claims-stack/lambdas.ts#L16-L17)). This can terminate the verifier or its Lambda execution environment before the malformed proof is rejected. Repeated requests can consume compute resources and disrupt legitimate claim submissions. Proof verification occurs before the backend checks whether a claim already exists ([`index.ts:185-193`](https://github.com/Emurgo/recovery-claims/blob/b2d701c0b217f4601b4c0bd95507562eb17b56cd/src/claims/create/index.ts#L185-L193)), so an attacker can repeatedly exercise the decoder with the same public affected key hash.

**Recommendation**. Reject proofs with an unexpected structure before calling gnark's `ReadFrom`. For the current destination circuit, decode the base64 input, require the canonical compressed proof length, require exactly one commitment, and reject trailing data.

**Client Response**. The client has acknowledged the finding and has implemented a fix in the `recovery-claims` repository. The audit team has reviewed the diff, retested and confirmed the fix is valid.

---

This report was published on the [zkSecurity Audit Reports](https://reports.zksecurity.xyz) site by [ZK Security](https://www.zksecurity.xyz), a leading security firm specialized in zero-knowledge proofs, MPC, FHE, and advanced cryptography. For the full list of audit reports, see [llms.txt](https://reports.zksecurity.xyz/llms.txt).
