Non-Custodial Signer Solution - Crossmint


Prepared by:

Halborn Logo

HALBORN

Last Updated 08/13/2025

Date of Engagement: June 4th, 2025 - July 3rd, 2025

Summary

100% of all REPORTED Findings have been addressed

All findings

26

Critical

0

High

7

Medium

8

Low

6

Informational

5


1. Summary

2. Introduction

Crossmint engaged Halborn to perform a security assessment of the Signer-Frames, Crossmint-SDK, Crossbit-main, and TEE-ts repositories. The assessment scope included these repositories, although only specific paths within some were included. Halborn was granted access to the source code to conduct security testing using automated tools and manual techniques to identify, detect, and validate potential vulnerabilities within the application.

3. Assessment Summary

The Halborn team was provided a timeline for the engagement and assigned a dedicated full-time security engineer to evaluate the security of the scoped assets. This engineer is a penetration testing expert with advanced expertise in web, mobile, reconnaissance, discovery, and infrastructure penetration testing.

The security assessment uncovered multiple vulnerabilities across the repositories, varying in severity. It is strongly recommended to remediate these issues promptly to mitigate unnecessary risks.

Seven (7) high-impact issues were identified during the assessment:

    • Host MitM and Insecure Default targetOrigin. The application accepts messages from any origin, allowing a man-in-the-middle attacker to inject or steal cross-origin data and compromise user sessions.

    • Environment Parameter Tampering via URL Override. An attacker can override configuration parameters through query strings, causing the backend to operate in an unintended environment and exposing sensitive functionality.

    • Lack of Content Security Policy and Frame-Ancestor Protection. The absence of a Content Security Policy (CSP) and clickjacking protections permits hostile iframes and arbitrary script execution, potentially leading to malware injection and data theft.

    • Potential Arbitrary JavaScript Injection. Unescaped user input is reflected into HTML, enabling execution of attacker-supplied JavaScript and full compromise of the victim’s browser session.

    • Potential Authentication Bypass with ACCESS_SECRET Unset. When the shared secret is missing, the server skips verification, allowing an unauthenticated caller to impersonate any user.

    • Log Injection and Use of X-Forwarded-For. User-controlled strings are logged without sanitization, enabling attackers to forge IP addresses, insert fake entries, and mislead incident responders.

    • Excessive Exposure of Key Shares. Key fragments are exposed in the browser memory allowing a potential attacker that has compromised the computer to extract them.

Nine (9) medium-impact issues were identified during the assessment:

    • Insecure Storage of Cryptographic Keys in localStorage. Persisting keys client-side exposes them to theft via cross-site scripting (XSS) or physical device access, undermining encryption guarantees.

    • Hard-coded and Weak Secret. The use of a static, low-entropy secret enables attackers to brute-force credentials offline within minutes.

    • Potential XSS and Reverse-Tabnabbing. Unsanitized URL parameters and unguarded target="_blank" links allow script injection and phishing attacks through malicious tabs.

    • IDOR in Public Key Derivation. Predictable identifiers permit one tenant to request another tenant’s key material, resulting in horizontal privilege escalation.

    • Lack of Rate Limiting. Unlimited authentication attempts and API requests facilitate credential-stuffing attacks and resource exhaustion.

    • Attestation Response Replay Attack. Previously captured attestation tokens can be replayed to gain illegitimate trust and bypass integrity checks.

    • Potential Timing Attack on Shared-Secret Comparison. Measurable processing time differences reveal partial key bytes, enabling gradual recovery of the secret.

    • Lack of Message Origin Validation. The application processes postMessage events without verifying the sender’s domain, allowing cross-site request forgery of privileged actions.

    • Master Keys Not Explicitly Removed from Memory. Keys remain in RAM after use, so memory dumps or crash reports could expose them to attackers.

Five (5) low-impact issues were identified during the assessment:

    • Excessive Data Shared in Attestation. The attestation payload includes unnecessary user attributes, increasing privacy risks if intercepted.

    • Missing event.source Validation Allows Same-Origin Message Spoofing. Attackers executing code within the same domain can forge trusted messages and manipulate application state.

    • Weak Randomness. Non-cryptographic random generators produce predictable tokens, reducing the effort required for brute-force attacks.

    • Excessive Error Information Exposed to Caller. Detailed stack traces and configuration values leak internal implementation details that could aid attackers.

    • Sensitive Data Logged to Console. Debug logging outputs secrets to browser consoles and monitoring tools, risking inadvertent disclosure.

Five (5) informational findings were identified during the assessment:

    • Denial of Service (DoS) via Uncontrolled Memory Allocation. Very large input sizes can cause high memory consumption, potentially crashing the service under stress conditions.

    • Documentation Issues. Incomplete or outdated security documentation may lead to insecure operational practices.

    • Potential Production DoS Bug. Concurrency flaws could allow a high-rate attacker to exhaust worker threads and degrade availability.

    • Over-Privileged ACCESS_SECRET. The shared secret grants broader access than necessary, increasing the blast radius if leaked.

    • Deterministic Generation of Master Keys (Risk Accepted). Reproducible key generation facilitates development but may weaken uniqueness across deployments; this risk has been formally accepted by stakeholders.

4. Test Approach and Methodology

Halborn employed both whitebox and blackbox methodologies according to the scope, combining manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy. Manual testing is essential to uncover flaws in logic, processes, and implementation, while automated techniques enhance coverage and quickly identify infrastructure vulnerabilities. The assessment methodology included, but was not limited to, the following phases and tools:

    • Mapping Content and Functionality of APIs and SDKs

    • Application Logic Flaws

    • Access Handling

    • Authentication and Authorization Flaws

    • Rate Limiting Tests

    • Input Handling

    • Source Code Review

    • Fuzzing of All Input Parameters

    • Logic Errors

5. Out of Scope

    • In the Signer-Frames repository, all contents except /test and /src/lib are in scope.

    • In the Crossmint-SDK repository, all contents except packages/client/rn-window and packages/client/window are in scope.

    • In the Crossbit-main repository, only the contents of libraries/products/wallets/ncs and apps/crossmint-nextjs/src/api/wallets/ncs.controller.ts are in scope.


6. RISK METHODOLOGY

Vulnerabilities or issues observed by Halborn are ranked based on the risk assessment methodology by measuring the LIKELIHOOD of a security incident and the IMPACT should an incident occur. This framework works for communicating the characteristics and impacts of technology vulnerabilities. The quantitative model ensures repeatable and accurate measurement while enabling users to see the underlying vulnerability characteristics that were used to generate the Risk scores. For every vulnerability, a risk level will be calculated on a scale of 5 to 1 with 5 being the highest likelihood or impact.
RISK SCALE - LIKELIHOOD
  • 5 - Almost certain an incident will occur.
  • 4 - High probability of an incident occurring.
  • 3 - Potential of a security incident in the long term.
  • 2 - Low probability of an incident occurring.
  • 1 - Very unlikely issue will cause an incident.
RISK SCALE - IMPACT
  • 5 - May cause devastating and unrecoverable impact or loss.
  • 4 - May cause a significant level of impact or loss.
  • 3 - May cause a partial impact or loss to many.
  • 2 - May cause temporary impact or loss.
  • 1 - May cause minimal or un-noticeable impact.
The risk level is then calculated using a sum of these two values, creating a value of 10 to 1 with 10 being the highest level of security risk.
Critical
High
Medium
Low
Informational
  • 10 - CRITICAL
  • 9 - 8 - HIGH
  • 7 - 6 - MEDIUM
  • 5 - 4 - LOW
  • 3 - 1 - VERY LOW AND INFORMATIONAL
Our penetration tests use the industry standard Common Vulnerability Scoring System (CVSS) to calculate the severity of our findings.

7. SCOPE

REPOSITORIES
(a) Repository: signer-frames
(b) Assessed Commit ID: 6f01641
(c) Items in scope:
  • All repo contents but /test and /src/lib.
(a) Repository: crossbit-main
(b) Assessed Commit ID: a2efd29
(c) Items in scope:
  • libraries/products/wallets/ncs
  • apps/crossmint-nextjs/src/api/wallets/ncs.controller.ts
(a) Repository: tee-ts
(b) Assessed Commit ID: bdfbbd5
(c) Items in scope:
  • All repo contents
(a) Repository: crossmint-sdk
(b) Assessed Commit ID: 0ce33ca
(c) Items in scope:
  • packages/client/rn-window
  • packages/client/window
(a) Repository: open-signer
(b) Assessed Commit ID: 6157d5f
(c) Items in scope:
  • The audited repos were moved into this new repository in these pull requests:
  • - https://github.com/Crossmint/open-signer/pull/2
  • - https://github.com/Crossmint/open-signer/pull/3
↓ Expand ↓
Remediation Commit ID:
Out-of-Scope: New features/implementations after the remediation commit IDs.

8. Assessment Summary & Findings Overview

Critical

0

High

7

Medium

8

Low

6

Informational

5

Impact x Likelihood

HAL-01

HAL-02

HAL-03

HAL-04

HAL-05

HAL-06

HAL-07

HAL-08

HAL-09

HAL-10

HAL-11

HAL-12

HAL-13

HAL-14

HAL-15

HAL-16

HAL-17

HAL-18

HAL-19

HAL-20

HAL-21

HAL-22

HAL-23

HAL-24

HAL-25

HAL-26

Security analysisRisk levelRemediation Date
Host MitM and Insecure Default Target OriginHighRisk Accepted - 07/10/2025
Environment Parameter Tampering via URL OverrideHighSolved - 07/01/2025
Lack of Content Security Policy and Frame Ancestor ProtectionHighSolved - 07/14/2025
Potential Arbitrary JavaScript InjectionHighSolved - 07/11/2025
Potential Authentication Bypass with ACCESS_SECRET UnsetHighSolved - 07/10/2025
Log Injection & use of x-forwarded-forHighSolved - 07/11/2025
Excessive Exposure of Key SharesHighSolved - 07/10/2025
Insecure storage of cryptographic keys in localStorageMediumSolved - 07/10/2025
Hardcoded and Weak SecretMediumSolved - 07/15/2025
Potential XSS & Potential Reverse-TabnabbingMediumSolved - 07/11/2025
IDOR in Public Key DerivationMediumNot Applicable - 07/11/2025
Lack of Rate LimitingMediumSolved - 07/14/2025
Attestation Response Replay AttackMediumSolved - 07/18/2025
Potential Timing-Attack on Shared Secret ComparisonMediumSolved - 07/09/2025
Lack of Message Origin ValidationMediumRisk Accepted - 07/11/2025
Master Keys Not Explicitly Removed from MemoryLowRisk Accepted - 07/14/2025
Too Much Data Shared in AttestationLowNot Applicable - 07/11/2025
Missing event.source Validation Allows Same-Origin Message SpoofingLowSolved - 07/11/2025
Weak RandomnessLowSolved - 07/15/2025
Excessive Error Information Exposed to CallerLowSolved - 07/11/2025
Sensitive Data Logged to ConsoleLowSolved - 07/15/2025
DoS via Uncontrolled Memory AllocationInformationalSolved - 07/16/2025
Other Documentation IssuesInformationalSolved - 07/18/2025
Potential Production DoS BugInformationalFuture Release - 07/15/2025
Too Privileged ACCESS_SECRETInformationalFuture Release - 07/15/2025
Deterministic generation of Master KeysInformationalRisk Accepted - 06/24/2025

9. Findings & Tech Details

9.1 Host MitM and Insecure Default Target Origin

//

High

Description

It was discussed with Crossmint the potential attack of a rouge host that impersonate the iframe to generate a key for the user itself instead of relaying on the Crossmint iframe to perform this action.

In the recommendation section of this issue you will be able to find the recommendations for this attack.

Proof of Concept

The EventsService initializes its CrossWindow messenger using a default targetOrigin of '*' when none is provided. An attacker who can host the parent frame (or control window.parent) can establish the handshake and send or receive messages, potentially invoking sensitive operations (e.g., signing or key management) without restriction.

  • src/services/events.ts

When no targetOrigin is provided in init(), the code falls back to '*' for the postMessage targetOrigin, allowing any origin to communicate.

async init(options?: {
  handshakeOptions?: HandshakeOptions;
  targetOrigin?: string;
}): Promise<void> {
  if (EventsService.messenger) {
    this.log('Messenger already initialized');
    return;
  }
  EventsService.messenger =
    'ReactNativeWebView' in window && window.ReactNativeWebView != null
      ? new RNWebViewChild({
          incomingEvents: signerInboundEvents,
          outgoingEvents: signerOutboundEvents,
        })
      : new ChildWindow(window.parent, options?.targetOrigin ?? '*', {  // <-- default '*' used here
          incomingEvents: signerInboundEvents,
          outgoingEvents: signerOutboundEvents,
          handshakeOptions: options?.handshakeOptions,
        });

ChildWindow is instantiated with targetOrigin '*', meaning the messaging channel will accept and send messages to any origin, enabling a malicious parent frame to hijack the communication channel.

new ChildWindow(window.parent, options?.targetOrigin ?? '*', {
  incomingEvents: signerInboundEvents,
  outgoingEvents: signerOutboundEvents,
  handshakeOptions: options?.handshakeOptions,
});

Score
CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N(8.1)
Recommendation

After discussing this potential issue with the Crossmint team, they clarified that it is expected for anyone to be able to load and interact with the iframe. However, due to the risk of a host behaving maliciously, it is recommended to ask the companies embedding the iframe to specify the domains they wish to allow for communication with the iframe. Additionally, whenever the iframe is loaded, the request should include some form of an ID that identifies the company loading the iframe. By knowing the company responsible for loading the iframe, the Content Security Policy (CSP) set in the HTTP headers should restrict iframe loading to the approved domains. Moreover, the JavaScript code should verify that the origin of any received message is one of these authorized domains.

Remediation Comment

RISK ACCEPTED: The following changes were applied but as dicussed with the team this is not enough to prevent the issue: https://github.com/Crossmint/open-signer/pull/23

However it's important to notice that:

  • No passive or external MITM is possible, the host application must push malicious code updates, and any attack still requires the user to enter their OTP from their email into the host application.

  • Malicious host applications may only target their own signers.


Remediation Hash

9.2 Environment Parameter Tampering via URL Override

//

High

Description

The application allows users to override critical environment parameters using URL query strings. By manipulating these parameters, an attacker can force the application to operate in unintended modes (e.g., development, staging, or test). This may disable security features, enable debug outputs, switch to insecure configurations, or expose sensitive internal data—regardless of the intended deployment environment.

Proof of Concept

An attacker can override the application's runtime environment by controlling the environment query parameter in the URL. Even on production hosts, providing ?environment=development or ?environment=staging causes the application to run in a lower-security environment, potentially disabling production safeguards, pointing to insecure backends, or leaking sensitive data.

  • src/services/environment.ts

The code reads the environment parameter directly from the URL query string without restricting its use to development hosts.

const urlParams = new URLSearchParams(window.location.search);
const envParam = urlParams.get('environment');

Any string matching an allowed environment is accepted and returned, allowing an attacker to force the application into development or staging mode.

if (isEnvironment(envParam)) {
  return envParam;
}

Since the override is applied for all hostnames except exactly 'localhost', a malicious query parameter can downgrade a production environment to staging or development.

export function getEnvironment(): Environment {
  if (window.location.hostname === 'localhost') {
    return 'development';
  }
  // URL parameter override applied here on all hosts except localhost
  const urlParams = new URLSearchParams(window.location.search);
  const envParam = urlParams.get('environment');
  if (isEnvironment(envParam)) {
    return envParam;
  }
  return DEFAULT_ENVIRONMENT;
}

Score
CVSS:3.0/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:N(8.1)
Recommendation

Remove the URL-based environment override in production. Only allow environment to be overridden when running on localhost (development) or in a production build, don't allow it at all. Do not parse or honor the environment query parameter on any other host, like in:

export function getEnvironment(): Environment {
  // Always development on localhost
  if (window.location.hostname === 'localhost') {
    return 'development';
  }
  // In all other cases, ignore URL parameters and use the default
  return DEFAULT_ENVIRONMENT;
}

Remediation Comment

SOLVED: This issue was solved in the PR https://github.com/Crossmint/crossmint-sdk/pull/1204 and https://github.com/Crossmint/signer-frames/pull/35 by disallowing the environment to be set using a query parameter.

Remediation Hash

9.3 Lack of Content Security Policy and Frame Ancestor Protection

//

High

Description

The application does not implement a Content Security Policy (CSP) or frame ancestor restrictions. Without these protections, attackers can embed the site within malicious iframes or inject untrusted scripts. This increases the risk of attacks such as clickjacking, data theft, and cross-site scripting (XSS), exposing users and sensitive data to compromise.

Proof of Concept

The documentation mentions: "This isolation is enforced by: hardware-level memory protection, browser process separation, strict origin and frame ancestor validation, content Security Policy controls that strictly lock down network access."

However, when the iframe from https://signers.crossmint.com is loaded, the Content Security Policy (CSP) is only set to frame-ancestors * which allows any origin to embed the iframe. This means that any website can load the Crossmint iframe, potentially allowing malicious sites to interact with it. Moreover, there is also no validation of the JS scripts, images... that are loaded or contacted.

By itself this doesn't introduce any vulnerability, but if at some point any vulnerability is introduced in the iframe, the CSP won't protect it.

Score
CVSS:3.0/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:N(8.0)
Recommendation

Due to the sensitivity of the data being handled, it is recommended to:

  • Restrict the Content Security Policy (CSP) to only allow trusted origins to embed the iframe (check the Insecure Default Target Origin in postMessage section for more details).

  • Implement strict validation of all scripts, images, and other resources loaded within the iframe.

  • Regularly review and update the CSP to adapt to new threats and vulnerabilities.

Moreover, it's highly recommend to apply the CSP in both the HTTP headers and the meta tag of the HTML page. This ensures that the CSP is applied consistently across all browsers and scenarios, as some browsers and mobile application webviews may not fully respect the CSP defined only in the HTTP headers.

Remediation Comment

SOLVED: The PR https://github.com/Crossmint/open-signer/pull/28 is adding a restrictive CSP without ' unsafe-inline' and adding the SHA hash of the allowed JS script to run.

Remediation Hash

9.4 Potential Arbitrary JavaScript Injection

//

High

Description

If the injectedGlobals prop or any injected JavaScript is not properly controlled, an attacker or misconfigured code could inject and execute arbitrary JavaScript inside the WebView before the web content loads. This means any script—malicious or not—would run with the same privileges as the loaded page, potentially:

Because the injected code runs in the context of the page, it has access to everything the page does, making this a powerful and dangerous capability if misused or exposed.


Proof of Concept

The RNWebView component directly concatenates the string supplied in the injectedGlobals prop into the JavaScript that is executed inside the WebView context. Any caller that allows user-controlled input to reach injectedGlobals can therefore inject and run arbitrary JavaScript in the embedded WebView, leading to XSS-style attacks, credential theft, or privilege escalation inside the mobile application.

  • Vulnerable code located at: packages/client/rn-window/src/rn-webview/RNWebView.tsx

export const RNWebView = React.forwardRef<WebView, RNWebViewProps>(({ injectedGlobals = "", ...props }, ref) => {
    const combinedInjectedJs = `
        ${INJECTED_BRIDGE_JS}
        ${injectedGlobals}
    `;
    return <WebView ref={ref} {...props} injectedJavaScriptBeforeContentLoaded={combinedInjectedJs} />;
});

Attack flow:

  1. An malicious developer using the SDK controls the value passed to the injectedGlobals prop (e.g., it is derived from a remote configuration, deep-link parameter, or other untrusted source).

  2. The value is blindly interpolated into combinedInjectedJs.

  3. The resulting string is injected as injectedJavaScriptBeforeContentLoaded, which the WebView executes with full DOM access before any page content loads.

  4. The attacker-supplied code now runs inside the WebView, able to tamper with page content, intercept JWTs or wallet information, or exploit additional WebView bridges.


Score
CVSS:3.0/AV:L/AC:H/PR:N/UI:R/S:C/C:H/I:H/A:N(7.4)
Recommendation

If possible, stop using injectedGlobals and calculate inside the function the content that should be gathered instead of allow any function to inject arbitrary code.

If not possible, treat injectedGlobals as untrusted. Sanitise or restrict it to a whitelist of safe assignments or regex, or remove the prop entirely and expose a safer API that only allows key/value injection rather than raw code.


Remediation Comment

SOLVED: The issue was solved in https://github.com/Crossmint/crossmint-sdk/pull/1268 preventing arbitrary JS injection by the apps developer transforming into JSON all the values passed.

Remediation Hash

9.5 Potential Authentication Bypass with ACCESS_SECRET Unset

//

High

Description

If ACCESS_SECRET is unset, any logic relying on it for verifying requests may silently skip authentication and default to insecure behavior. This creates an important vulnerability where any request is treated as valid, potentially granting unauthorized access to sensitive operations, administrative interfaces, or user data. Attackers could exploit this misconfiguration to bypass authentication entirely, especially in environments where the absence of the secret isn't explicitly handled as an error.

Proof of Concept

The authMiddleware compares the ACCESS_SECRET with the given value in the HTTP header:

export const authMiddleware = () => {
	return createMiddleware<AppEnv>(async (c, next) => {
		const accessSecret = c.get("env").ACCESS_SECRET;
		const logger = c.get("logger");
		const authorizationHeader = c.req.header("authorization");

		if (authorizationHeader !== accessSecret) {
			logger.warn("[Auth] Unauthorized attempt", {
				url: c.req.url,
				client_ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
			});
			throw new HTTPException(401, { message: "Unauthorized" });
		}
		await next();
	});
};

If for some reason the env variable wasn't properly set and the HTTP header is not sent in the HTTP request, then undefined will be compared with undefined bypassing this check.

Score
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:N(7.4)
Recommendation

It's recommended to make createMiddleware throw an error if c.get("env").ACCESS_SECRET is undefined or empty.

Remediation Comment
Remediation Hash

9.6 Log Injection & use of x-forwarded-for

//

High

Description

Attackers can set a custom X-Forwarded-For header, and if your server logs that value verbatim it becomes an untrusted, user-controlled string embedded in security logs. By injecting newline characters or log-format tokens, an attacker can perform log injection (also called log forging)—splicing fake entries, erasing their own tracks, or planting misleading records to frame someone else. Worse, if defensive logic (rate-limiting, geo blocks, fraud scoring) trusts the logged IP, the attacker can spoof a “good” address and slip past controls. Combined, these issues turn your logs from a reliable audit trail into a tool for obfuscation, evasion, and even remote-code execution in poorly-hardened log parsers.

Proof of Concept

The following code was found to be trusting the content of the HTTP header x-forwarded-for which is not recommended as usually an attacker will be able to modify the content of this header when he sends a request.

  • src/middleware/logger.middleware.ts

logger.info(`--> ${method} ${requestUrl}`, {
			http: {
				method: method,
				url: requestUrl,
				client_ip:
					c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
					c.req.header("cf-connecting-ip") ||
					c.req.header("x-real-ip"),
			},
			network: {
				client: {
					ip:
						c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
						c.req.header("cf-connecting-ip") ||
						c.req.header("x-real-ip") ||
						c.env?.REMOTE_ADDR,
				},
			},
		});
  • src/middleware/auth.middleware.ts

export const authMiddleware = () => {
	return createMiddleware<AppEnv>(async (c, next) => {
		const accessSecret = c.get("env").ACCESS_SECRET;
		const logger = c.get("logger");
		const authorizationHeader = c.req.header("authorization");

		if (authorizationHeader !== accessSecret) {
			logger.warn("[Auth] Unauthorized attempt", {
				url: c.req.url,
				client_ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
			});
			throw new HTTPException(401, { message: "Unauthorized" });
		}
		await next();
	});
};

Score
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N(7.2)
Recommendation

It's recommended to not use x-forwarded-for as it's a well known http headers for attackers. It's recommended to use better other http headers like cf-connecting-ip or x-real-ip before trusting x-forwarded-for. Moreover, if you expect only requests from the Relay you could use a custom and secret HTTP header to send this information about the client connecting to the Relay.

Moreover, it's also highly recommended to also sanitize the header values. For example, if you are expecting an IP address, use a regex to check that the received value is actually an IP address before using that value.


Remediation Comment

SOLVED: This issue was solved by removing the use of X-Forwarded-For in https://github.com/Crossmint/open-signer/pull/30

Remediation Hash

9.7 Excessive Exposure of Key Shares

//

High

Description

One of the bases of the security model of this platform is to have the key share pairs separated in different locations. However, it was discovered that most of the time they can actually be found in the clients computer memory, making this security model weaker than expected.

Proof of Concept

The docs mention: "Crossmint backend (never cached longer than 10 min on the client)"

In the file auth-share-cache.ts it's possible to see that the CACHE_TTL_MS sets a cache time of 5minutes, which means that is the auth share is used after this time, it will be fetched again from the backend. However, the value of cached.authKeyShare is not deleted after this time, so the auth share is stored in the browser indefinitely allowing an attacker that manages to compromise the laptop of the user to get the auth share and use it to sign transactions.

Moreover, the file sharding.ts stores the deviceShare in the local storage in:

  public storeDeviceShare(signerId: string, share: string): void {
    localStorage.setItem(this.deviceShareStorageKey(signerId), share);
  }

Moreover, the reconstructMasterSecret function is the one that fetched the 'auth share' from the backend, and this function is called at the beginning and the end of the onboarding. meaning that the 'auth share' and the ' device share' are always store inside the memory of the browser while the user is using the application.

The documentation also mentions: "This isolation is enforced by: hardware-level memory protection, browser process separation [...] that strictly lock down network access."

However, this is not true in Windows, some Linux systems and macOS systems with SIP disabled, where the memory is not isolated and an attacker that compromises the users laptop (physically or via a remote malware) can dump the memory and get the auth share and the device share.

Score
CVSS:3.0/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N(7.1)
Recommendation
  • Do not automatically download the auth share if not necessary, and, in any case, after 5 minutes have passed, the auth share should be deleted from the memory.

  • The device share should not be stored in the local storage, but in memory only. And If it must stored in the local storage, it's recommended to store it somehow encrypted and store the key in the backend. This way, whenever the user needs to access the device share, it can be fetched from the backend the same way the auth share is fetched. Moreover, the key to decrypt the device share and the unencrypted device share should be removed from memory after the user is done using it (or after 5mins like the auth share).


Remediation Comment

SOLVED:


Remediation Hash

9.8 Insecure storage of cryptographic keys in localStorage

//

Medium

Description

The application stores cryptographic keys in the browser’s localStorage. localStorage is accessible to any JavaScript running on the page, including potentially malicious scripts injected through XSS vulnerabilities. This exposes sensitive key material to theft, undermining the security of encrypted data and authentication mechanisms.

Proof of Concept

The EncryptionService persists the private and public key pair in window.localStorage under STORAGE_KEYS.PRIV_KEY and STORAGE_KEYS.PUB_KEY. Because localStorage is accessible via any JavaScript running in the origin (e.g., XSS), an attacker can read and exfiltrate the private key, compromising confidentiality and allowing decryption or key misuse.

  • src/services/encryption.ts

The service serializes the private key to a JWK bundle and writes it to localStorage, where any script can access it.

private async saveKeyPairToLocalStorage(): Promise<void> {
  if (!this.ephemeralKeyPair) throw new Error('Encryption key pair not initialized');
  const serializedPrivKey = await this.serializePrivateKey(this.ephemeralKeyPair.privateKey);
  const serializedPubKey = await this.serializePublicKey(this.ephemeralKeyPair.publicKey);
  localStorage.setItem(STORAGE_KEYS.PRIV_KEY, this.bufferToBase64(serializedPrivKey));
  localStorage.setItem(STORAGE_KEYS.PUB_KEY, this.bufferToBase64(serializedPubKey));
}

Score
CVSS:3.0/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N(6.8)
Recommendation

Store private keys in a secure, sandboxed context such as the Web Crypto API’s IndexedDB-backed key storage (CryptoKeyStorage) and do not serialize or expose raw key material to JavaScript.

// Example using IndexedDB-backed subtleCrypto storage
const key = await crypto.subtle.generateKey(
  { name: 'ECDH', namedCurve: 'P-384' },
  true,
  ['deriveKey', 'deriveBits']
);
await crypto.subtle.exportKey('jwk', key.privateKey); // export only if needed, otherwise keep in secure storage
// Do NOT store in localStorage. Rely on subtleCrypto to keep the key non-extractable.

Remediation Comment

SOLVED:


Remediation Hash

9.9 Hardcoded and Weak Secret

//

Medium

Description

Hard-coding API keys, passwords, keys...—and choosing them weak—means anyone who gets a copy of the binary or source can read the secret, clone the request, and walk straight into your service. Because the secret is baked into the code, you can’t rotate it quickly, so one leak compromises all deployments. A weak secret adds a second danger: attackers can brute-force or guess it in minutes, opening the door even if the code itself stays private. Together, hardcoded + weak secrets turn your entire security model into a single, fragile point of failure.

Proof of Concept

In wallets.ts was found the following line of code:

TEE_ACCESS_SECRET: z.string().default("top-secret-access-code"),

After discussing with the client it was confirmed that this is actually the secret used nowadays (during the development process). It's expected to be modified, but until it's fixed this is a security issue to have in mind.

The same secret is also hardcoded in .env.example inside the TEE application source code:

ACCESS_SECRET=top-secret-access-code

Score
CVSS:3.0/AV:N/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:N(6.8)
Recommendation

I's recommended to not hardcode the secret in the code and use environment variables stored in a secure storage and to increase the complexity of the secret.

Remediation Comment

SOLVED: The hardcoded secret was removed from the code at https://github.com/Paella-Labs/crossbit-main/pull/19635

Remediation Hash

9.10 Potential XSS & Potential Reverse-Tabnabbing

//

Medium

Description

The application includes input or output handling flaws that may allow attackers to inject and execute malicious scripts in a user's browser. This can lead to session hijacking, credential theft, or unauthorized actions performed on behalf of the user.
Moreover, links in the application open in new browser tabs without proper security attributes (e.g., rel="noopener"). This allows the newly opened tab to control the original page via window.opener, enabling phishing, data theft, or malicious redirects.

Proof of Concept

The url parameter received by NewTabWindow.init() is forwarded directly into window.open without any scheme or protocol validation. An attacker could provide a url such as javascript:alert(document.domain). Modern browsers will execute the JS in the new window and still expose window.opener, which may aid phishing or further attacks. Because the library does not sanitize or reject non-http/https schemes, consuming applications may inadvertently enable this XSS vector.

Moreover, any caller of NewTabWindow.init(..) can pass an attacker-controlled URL. The SDK opens this URL with window.open using the target "_blank" but without the "noopener" or "noreferrer" feature flags. The newly opened (potentially malicious) page keeps a handle to window.opener and can therefore navigate the calling application to a phishing site or execute additional JavaScript in the opener context (reverse-tabnabbing).

In packages/client/window/src/windows/NewTab.ts:

const _window = window.open(url, "_blank");

Score
CVSS:3.0/AV:L/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N(6.3)
Recommendation

Validate the URL scheme before calling window.open. Allow only http:, https: (and optionally about:blank) and add the "noopener" and "noreferrer" feature flags. Reject or sanitize anything else.

For example, you can use a regex to validate the URL:

const originMatches = this.isTargefunction safeUrl(url: string): string {
    const parsed = new URL(url, window.location.origin);
    if (!/^https?:$/.test(parsed.protocol)) {
        throw new Error("Unsupported URL scheme");
    }
    return parsed.href;
}
...
const _window = window.open(safeUrl(url), "_blank", "noopener,noreferrer");

Remediation Comment

SOLVED: The issue was solved in the PR https://github.com/Crossmint/crossmint-sdk/pull/1272 by adding checks for the use of other schemes than https and adding the required flags to avoid reverse-tabnaving and later it was modified by https://github.com/Crossmint/crossmint-sdk/pull/1272

Remediation Hash

9.11 IDOR in Public Key Derivation

//

Medium

Description

IDOR (Insecure Direct Object Reference) is a vulnerability where an application exposes a direct reference—such as a numeric ID, filename, or key—to an internal object and fails to verify that the current user is authorized to access it.

Proof of Concept

The function forwardNCSPreGeneration trust the received authId in the HTTP request from the user and forwards this value to the TEE which also trust the value and generates a public key for that signer and authId. This allows users of a host application to generate public keys for other users just knowing their email address.

The following function received the request from the user in the /derive-public-key endpoint without sanitizing the received data in the body (the authId goes inside the body):

@Post("/derive-public-key")
    @Cors()
    @UseGuards(RequiredJWTGuard([PublicScopes.WALLET_CREATE]))
    @ApiOperation({
        summary: "Deterministically generate a signer's public key for a specified signing curve",
    })
    async forwardPreGeneration(
        @Res({ passthrough: true }) res: Response,
        @Body() body: undefined | Record<string, unknown>,
        @DeveloperProjectParam() project: DeveloperProject,
        @RequiredUserParam() user: IUser
    ): Promise<unknown> {
        if (isProduction()) {
            throw new UnauthorizedException();
        }

        const forwarded = await this.service.forwardNCSPreGeneration(body, user._id.toString(), project._id.toString());
        res.status(forwarded.status);

        try {
            return await forwarded.clone().json();
        } catch (e) {
            console.error("ERROR", await forwarded.text());
            console.warn(`Error parsing TEE pre-generation response\n${e}\nResponding with empty body`);
        }
    }

The following function of the relay just relays the received authId to the TEE:

async forwardNCSPreGeneration(
        body: undefined | Record<string, unknown>,
        userId: string,
        projectId: string
    ): Promise<Response> {
        const payload = { ...body, signerId: `${userId}:${projectId}` };
        console.info({
            event: "NCSService.forwardNCSPreGeneration",
            payload,
            timestamp: new Date().toISOString(),
        });

        const url = `${env.TEE_SERVICE_URL}/v1/signers/derive-public-key`;
        return fetch(url, {
            method: "POST",
            body: JSON.stringify(payload),
            headers: {
                "Content-Type": "application/json",
                authorization: env.TEE_ACCESS_SECRET,
            },
        });
    }

The following code in the TEE application trusts without checking the authID given by the Relay:

export const derivePublicKeyHandler = async (c: AppContext) => {
	const services = c.get("services");
	const body = await c.req.json<SignerPreGenerationInput>();

	const { signerId, authId, keyType } = body;

	console.log("[DEBUG] POST /v1/signers/derive-public-key - Body:", body);

	const publicKey = await services.trustedService.derivePublicKey(
		signerId,
		authId,
		keyType,
	);

	return c.json({ publicKey });
};

Score
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N(5.3)
Recommendation

It's recommended to only allow to generate a public key for the current user and not for other users.

Remediation Comment

NOT APPLICABLE: This issue isn't aplicable because the value of req.user is being assigned in assertJwtUser from the JWT.

9.12 Lack of Rate Limiting

//

Medium

Description

Lack of rate limiting in web applications allows attackers to send an unrestricted number of requests to sensitive endpoints (e.g., login, password reset, API actions), enabling brute-force attacks, credential stuffing, scraping, and denial-of-service. Without enforcement of request thresholds per IP, user, or session, attackers can automate high-volume abuse without detection or blocking, compromising both security and availability of the service.

Proof of Concept

The application registers routes without any rate-limiting or throttling middleware, exposing it to potential denial-of-service attacks and TEE secret brute-forcing by allowing an attacker to flood endpoints with unlimited requests.

  • In src/index.ts in the TEE app:

const app = new Hono<AppEnv, "fetch">();
2app.route("/health", healthController);
3app.route("/v1/attestation", attestationController);
4app.route("/v1/signers", signerController);

Score
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L(5.3)
Recommendation

Introduce rate limit middleware (e.g., using Hono’s middleware or a third-party library) to cap requests per IP or API key. Implement exponential backoff or circuit breaker patterns.

Example:

import { RateLimit } from 'hono/rate-limit';
const app = new Hono<AppEnv>();
app.use('*', RateLimit({
  windowMs: 60_000,
  max: 100, // max 100 requests per minute per IP
  message: 'Too many requests, please try again later.'
}));
// ... route registrations ...

Remediation Comment

SOLVED: A more restrictive rate limit was applied in https://github.com/Paella-Labs/crossbit-main/pull/19622

Remediation Hash

9.13 Attestation Response Replay Attack

//

Medium

Description

An Attestation Response Replay Attack occurs when an attacker captures a valid attestation response and reuses it later to impersonate a legitimate client or device. This is especially dangerous if the server accepts previously signed responses without verifying their freshness. To prevent this, it’s strongly recommended to include a client-supplied nonce and a timestamp in the attestation request and response. The nonce ensures uniqueness per session, making replayed responses invalid, while the timestamp allows the server to enforce expiration windows and detect delayed or reused responses. Together, they provide robust protection against replay attacks.

Proof of Concept

The attestation controller returns a static quote and encrypted public key without including any client-specific nonce or timestamp. An attacker can replay a previously recorded attestation response to bypass freshness checks on the client side.

The function getAttestationHandler is not adding any received nonce or adding any timestamp to the returned data, allowing to perform replay attacks with the returned data:

export const getAttestationHandler = async (c: AppContext) => {
	const services = c.get("services");
	const pubKeyBuffer = await services.encryptionService.getPublicKey();
	const result = await new TappdClient().tdxQuote(new Uint8Array(pubKeyBuffer));

	return c.json({
		...result,
		publicKey: Buffer.from(pubKeyBuffer).toString("base64"),
	});
};

Score
CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N(5.3)
Recommendation

Include a client-supplied nonce in the request and embed it in the attestation response, also consider adding a signed timestamp to the quote so that clients can verify freshness.

Remediation Comment

SOLVED: The timestamp was added in the PR https://github.com/Crossmint/open-signer/pull/39

Remediation Hash

9.14 Potential Timing-Attack on Shared Secret Comparison

//

Medium

Description

Comparing two shared-secret byte arrays with a naïve === or for-loop breaks early on the first mismatching byte, so the total execution time leaks how many leading bytes match; an attacker who can measure that delay repeatedly—such as over a network with many tries—can do a byte-by-byte binary search to recover the entire secret (classic timing attack). Once the secret is revealed, they can forge MACs, bypass authentication, or decrypt data protected by that secret. The fix is to use a constant-time comparison routine (often provided by crypto libraries) that always processes the full length of both inputs and avoids branch-based early exits, eliminating observable time differences.

Proof of Concept

The code compares the attacker-controlled Authorization header with the secret using the JavaScript !== operator. This comparison short-circuits on the first non-matching byte, allowing an attacker to measure response times and incrementally guess the secret (a classic timing attack).

  • src/middleware/auth.middleware.ts

export const authMiddleware = () => {
	return createMiddleware<AppEnv>(async (c, next) => {
		const accessSecret = c.get("env").ACCESS_SECRET;
		const logger = c.get("logger");
		const authorizationHeader = c.req.header("authorization");

		if (authorizationHeader !== accessSecret) {
			logger.warn("[Auth] Unauthorized attempt", {
				url: c.req.url,
				client_ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim(),
			});
			throw new HTTPException(401, { message: "Unauthorized" });
		}
		await next();
	});
};

Score
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:N(4.8)
Recommendation

Use constant-time comparison such as crypto.timingSafeEqual and ensure both inputs are equal-length buffers.

import { timingSafeEqual } from "node:crypto";
...
const a = Buffer.from(authorizationHeader || "");
const b = Buffer.from(accessSecret);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
  throw new HTTPException(401, { message: "Unauthorized" });
}
await next();

Remediation Comment
Remediation Hash

9.15 Lack of Message Origin Validation

//

Medium

Description

Failing to verify where a message really comes from turns any inter-component channel—window.postMessage, WebSocket, or even an in-app event bus—into an open door for attackers. A malicious page, script, or process can pose as a trusted sender and inject forged commands, credentials, or data, tricking the receiver into performing actions it should reserve for a legitimate origin. This breaks the assumed trust boundary, enabling cross-site scripting, CSRF-style requests, privilege escalation, and data leakage. Always check the origin (and other context like channel, token, or signature) before acting on a message so that only authentic, expected sources are obeyed.

Proof of Concept

It's understood that the goad of this SDK and platform is for everyone to be able to use it. However, it's still recommended to force the developers to secure the access to the message channel forcing the origin checks, making the developers need to indicate the valid origins. The following are the 2 main places were origins should be checked for each. message:

The global message event listener in RNWebViewTransport (handleGlobalMessage) does not validate the origin of incoming messages. This lack of verification might allow external scripts (within the same context) to send crafted messages that could be processed by the application, leading to unexpected behavior.

  • Vulnerable code from: src/transport/RNWebViewTransport.ts

private handleGlobalMessage = (event: MessageEvent) => {
        this.dispatchToListeners({
            type: "message",
            data: event.data,
        });
    };

Moreover, within the WindowTransport class, when the targetOrigin is set to '*', the isTargetOrigin method returns true for any origin. This could potentially allow malicious actors from any domain to interact with the application if '*' is inadvertently used in a production environment.

  • Vulnerable code from src/transport/WindowTransport.ts

protected isTargetOrigin(otherOrigin: string) {
        if (Array.isArray(this.targetOrigin)) {
            return this.targetOrigin.includes(otherOrigin);
        }

        if (this.targetOrigin === "*") {
            return true;
        }
        return this.targetOrigin === otherOrigin;
    }

Score
CVSS:3.0/AV:N/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N(4.2)
Recommendation

Implement origin (or source) validation in the handleGlobalMessage method to ensure that only messages from trusted origins are processed. For instance, check the event.origin property against a whitelist of allowed origins like:

private handleGlobalMessage = (event: MessageEvent) => {
    // Add origin validation here
    const allowedOrigins = ['https://trusted.origin.com'];
    if (!allowedOrigins.includes(event.origin)) {
        console.warn('Discarding message from untrusted origin:', event.origin);
        return;
    }
    this.dispatchToListeners({
        type: "message",
        data: event.data,
    });
};

Moreover, it's recommended to disallow the use of * in the function isTargetOrigin.

Of course, these recommendations will affect the usability of this SDK, so it's recommended to the Crossmint team to check them.

Remediation Comment

RISK ACCEPTED: The Crossmint team says that any host application must be able to use the tool, and so they won’t be creating a list of trusted origins, instead they’ll be relying on other security mitigations for the listed attacks.

9.16 Master Keys Not Explicitly Removed from Memory

//

Low

Description

Crossmint TEE stores every master keys generated for an arbitrary time in memory after generating it. It's recommended to add some code that tries to remove this data from memory as soon as possible.

Proof of Concept

This is the code that generates the master key:

private async deriveMasterSecret(
		signerId: string,
		authId: string,
	): Promise<Uint8Array> {
		const info = new TextEncoder().encode(
			JSON.stringify({
				signer_id: signerId,
				auth_id: authId,
				version: "1",
			}),
		);

		const derivationSecret = await crypto.subtle.exportKey(
			"pkcs8",
			this.identityKey.privateKey,
		);

		const keyMaterial = await crypto.subtle.importKey(
			"raw",
			derivationSecret,
			{ name: "HKDF" },
			false,
			["deriveBits"],
		);

		const derivedBits = await crypto.subtle.deriveBits(
			{
				name: "HKDF",
				hash: this.HASH_ALGORITHM,
				salt: new Uint8Array(32),
				info,
			},
			keyMaterial,
			256,
		);

		return new Uint8Array(derivedBits);
	}

Every variable that stores this data should be overwritten and the garbage collector should be called asap.

Score
CVSS:3.0/AV:L/AC:H/PR:H/UI:R/S:C/C:L/I:L/A:N(3.7)
Recommendation

It's recommended to overwrite all the variables that contain the master key data after it's sent back to the client and call the garbage collector to ensure that the master key information is removed from memory as soon as possible after generating it.

Remediation Comment

RISK ACCEPTED: The Crossmint team indicated that they will accept the risk.

9.17 Too Much Data Shared in Attestation

//

Low

Description

The attestation endpoint of the Relay and TEE might be sharing too much information which could be omited.

Proof of Concept

When accessed the relay in /v1/signersv1/signers it'll just send a request to the TEE server with the needed secret:

async getAttestation(): Promise<Record<string, unknown>> {
        const response = await fetch(`${env.TEE_SERVICE_URL}/v1/attestation`, {
            headers: {
                authorization: env.TEE_ACCESS_SECRET,
            },
        });
        if (!response.ok) {
            throw new Error(`Failed to get attestation:  ${await response.text()}`);
        }
        return response.json();
    }

The TEE will return the complete TdxQuoteResponse object which contains and event_log which potentially doesn't need to be shared with a user and could contain interesting TEE internal information.

Score
CVSS:3.0/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N(3.7)
Recommendation

It's recommended to check the data returned when the attestation endpoint is accessed and remove all the information that is not needed by the user, potentially like the event_log

Remediation Comment

NOT APPLICABLE: The Crossmint team indicated that the information sent by this endpoint is actually needed:

  • The event log is less obvious, it contains application information we use for integrity checks, like the hash of the application code and configuration through the compose-hash and the phala deployment appId. These important event log values aren't directly attested to, instead the Rtmr3, which is the hash chain of many different event logs, is included in the attestation. To verify TEE application integrity, we reconstruct the Rtmr3 value using the event log then confirm it matches the attested value. Once confirmed, we check that the appId is expected, and we'll soon add a compose-hash check.


9.18 Missing event.source Validation Allows Same-Origin Message Spoofing

//

Low

Description

The application’s message handler validates only the message origin (event.origin) and not the source window (event.source). This allows any window or iframe from the same origin to spoof trusted messages by posting crafted data. Attackers can abuse this to bypass trust boundaries between parent and child frames, injecting malicious messages that may trigger sensitive actions in the application.

Proof of Concept

WindowTransport accepts a Window reference (otherWindow) but, during message reception, it ONLY validates event.origin and ignores event.source. An attacker controlling any window (e.g., another iframe) under the same origin can therefore inject messages (step-1). Because origin matches, the listener is invoked (step-2) and malicious data reaches SDK callbacks (step-3), potentially bypassing expected parent/child trust boundaries.

Vulnerable code from packages/client/window/src/transport/WindowTransport.ts

const wrapped = (event: MessageEvent) => {
    const originMatches = this.isTargetOrigin(event.origin);
    if (originMatches) {
        listener({ type: event.type, data: event.data } as SimpleMessageEvent);
    }
};

An attacker in a different window or iframe but with the same origin as the legitimate parent posts a crafted message:

window.postMessage({ event: "handshakeComplete", data: {/* fake */} }, "https://trusted.example.com");

Score
CVSS:3.0/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N(3.6)
Recommendation

Validate both origin AND source. Only accept messages whose event.source === otherWindow (or a known list).

const wrapped = (event: MessageEvent) => {
    const sameSource = event.source === this.otherWindow;
    const originMatches = this.isTargetOrigin(event.origin);
    if (sameSource && originMatches) {
        listener({ type: event.type, data: event.data } as SimpleMessageEvent);
    }
};

Remediation Comment

SOLVED: Fixed in https://github.com/Crossmint/crossmint-sdk/pull/1282/files verifying the origin of the message.

Remediation Hash

9.19 Weak Randomness

//

Low

Description

The application uses insecure or predictable random number generation (such as Math.random() or other non-cryptographic sources) for security-sensitive operations. This weak randomness can be exploited by attackers to guess tokens, session IDs, or other values, leading to unauthorized access, token forgery, or other security breaches.

Proof of Concept

The handshake protocol relies on generateRandomString() to create a requestVerificationId that must be unpredictable. However, it is based on Math.random() owhich is a non-cryptographically secure PRNG, an attacker in a malicious child window could guess the identifier and inject a forged handshakeResponse before the legitimate child answers, hijacking the session.

  • packages/client/window/src/utils/generateRandomString.ts

export const generateRandomString = () => Math.random().toString(36).substring(2, 15);

Score
CVSS:3.0/AV:L/AC:H/PR:N/UI:R/S:U/C:L/I:L/A:N(3.6)
Recommendation

Ensure generateRandomString() uses crypto.getRandomValues() (browser) or crypto.randomBytes() (Node) to obtain cryptographically secure randomness, and produce at least 128 bits of entropy. For example:

export function generateRandomString(length = 13): string {
  const bytes = new Uint8Array(length);
  crypto.getRandomValues(bytes);

  // Map each byte to a single base-36 digit (0–9, a–z)
  return Array.from(bytes, b => (b % 36).toString(36)).join("");
}

Remediation Comment

SOLVED: A secure random number generator is now used.


Remediation Hash

9.20 Excessive Error Information Exposed to Caller

//

Low

Description

Certain functions disclose overly detailed error information to the caller. This can leak sensitive details about the application's internal logic, configuration, or underlying technologies. An attacker could use this information to craft more effective attacks, identify vulnerabilities, or bypass security controls.

Proof of Concept

EventHandler.callback() catches all errors and returns error.message and error.code to the caller, potentially leaking internal implementation details (e.g., stack traces, shard hashes) to untrusted clients.

  • src/services/handlers.ts

catch (error: unknown) {
  const errorResponse = {
    status: 'error',
    error: error instanceof Error ? error.message : 'Unknown error',
    ...(error instanceof XMIFCodedError && { code: error.code }),
  };
  return errorResponse;
}

Score
CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:N(3.5)
Recommendation

Return generic error messages to the client while logging the detailed error internally. Avoid including stack traces, internal codes, or implementation-specific messages in API responses like in:

// Example: Generic error for client
catch (error: unknown) {
  console.error('Internal handler error:', error);
  return { status: 'error', error: 'An internal error occurred' };
}

Remediation Comment

SOLVED: The issue was solved in https://github.com/Crossmint/open-signer/pull/31 sending back to the user a generic error message whenever an error occurs.

Remediation Hash

9.21 Sensitive Data Logged to Console

//

Low

Description

The application logs sensitive information to the browser’s developer console or server logs. This exposes confidential data to anyone with access to the logs, including users, developers, or attackers exploiting XSS or other vulnerabilities, increasing the risk of data leakage and compromise.

Proof of Concept

The CrossmintRequest implementation logs request inputs, encrypted payloads, and decrypted responses, including authentication tokens and cryptographic material, to the browser console. Malicious browser extensions or shared consoles can access this data, leading to information exposure.

  • src/services/request.ts

private log = (...args: unknown[]) => {
  console.log(`[Request${this.name ? `: ${this.name}` : ''}]`, ...args);
};

async execute(input: I): Promise<O> {
  this.log(`Executing ${this.encrypted ? 'encrypted' : 'unencrypted'} ${this.method} request...`);
  this.log(`[TRACE] Parsing input ${JSON.stringify(input, null, 2)}...`);
if (this.encrypted) {
  const encryptedPayload = await this.encryptionService.encryptBase64(parsedInput);
  this.log('Encryption successful! Encrypted body: ', JSON.stringify(encryptedPayload, null, 2));
  return encryptedPayload;
}
if (this.encrypted) {
  this.log('Detected encrypted response. Decrypting...');
  this.log(`[TRACE] Parsing encrypted response ${JSON.stringify(apiResponse, null, 2)}...`);
  const response = await this.encryptionService.decrypt(
    parsedResponseData.ciphertext,
    parsedResponseData.encapsulatedKey
  );
  this.log(`[TRACE] Decrypted response: ${JSON.stringify(response, null, 2)}`);
}
  • libraries/products/wallets/ncs/src/ncs.service.ts

const payload = {
    ...body,
    projectName,
    projectLogo,
    signerId: `${userId}:${projectId}`,
    deviceId,
};
console.info({
    event: "NCSService.forwardNCSCreation",
    deviceId,
    payload,
    timestamp: new Date().toISOString(),
});
const payload = { ...body, signerId: `${userId}:${projectId}` };
console.info({
    event: "NCSService.forwardNCSPreGeneration",
    payload,
    timestamp: new Date().toISOString(),
});
const payload = { ...body, signerId: `${userId}:${projectId}` };
console.info({
    event: "NCSService.forwardSignerAuthAndStoreKeyshare",
    payload,
    timestamp: new Date().toISOString(),
});
  • apps/crossmint-nextjs/src/api/wallets/ncs.controller.ts

const attestation = await this.service.getAttestation();
console.log("Attestation:", JSON.stringify(attestation, null, 2));
    return await forwarded.clone().json();
} catch (e) {
    console.error("ERROR", await forwarded.text());
    console.warn(`Error parsing TEE signer creation response\n${e}\nResponding with empty body`);
    return await forwarded.clone().json();
} catch (e) {
    console.error("ERROR", await forwarded.text());
    console.warn(`Error parsing TEE signer auth response\n${e}\nResponding with empty body`);
    return await forwarded.clone().json();
} catch (e) {
    console.error("ERROR", await forwarded.text());
    console.warn(`Error parsing TEE pre-generation response\n${e}\nResponding with empty body`);

The TEE application is also logging sensitive information:

  • src/services/key.service.ts

console.log("[DEBUG] POST /v1/signers/derive-public-key - Body:", body);
  • `/v1/signers/start-onboarding

const otp = this.createRandomOTP();
console.log("[DEBUG] Generated OTP:", otp);
...
console.log("[DEBUG] Attempting to send email to:", recipient);
console.log(sendGridData);
  • src/middleware/error.handler.ts

export const globalErrorHandler: ErrorHandler<AppEnv> = (err, c) => {
	const logger = c.get("logger");
	logger.error(
		`[ERROR] Request to ${c.req.method} ${c.req.path} failed: ${err.message}`,
		{
			error: {
				message: err.message,
				stack: err.stack,
				name: err.name,
			},
			url: c.req.url,
			method: c.req.method,
		},
	);
  • src/middleware/logger.middleware.ts

export const requestLogger = (): MiddlewareHandler => {
	return async (c, next) => {
		const startTime = Date.now();
		const requestUrl = c.req.url;
		const method = c.req.method;
		const logger = c.get("logger");

		logger.info(`--> ${method} ${requestUrl}`, {
			http: {
				method: method,
				url: requestUrl,
				client_ip:
					c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
					c.req.header("cf-connecting-ip") ||
					c.req.header("x-real-ip"),
			},
			network: {
				client: {
					ip:
						c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
						c.req.header("cf-connecting-ip") ||
						c.req.header("x-real-ip") ||
						c.env?.REMOTE_ADDR,
				},
			},
		});

		try {
			await next();
			// biome-ignore lint/suspicious/noExplicitAny:
		} catch (error: any) {
			const durationMs = Date.now() - startTime;
			logger.error(
				`<-- ${method} ${requestUrl} - Unhandled Error after ${durationMs}ms`,
				{
					http: {
						method: method,
						url: requestUrl,
						status_code: 500,
						duration_ms: durationMs,
					},
					error: {
						message: error.message,
						stack: error.stack,
						kind: error.name,
					},
				},
			);
			throw error;
		}
  • docker-entrypoint.sh

echo "SSL_CERTIFICATE: $(cat /etc/nginx/ssl/cert.pem)"
echo "SSL_CERTIFICATE_KEY: $(cat /etc/nginx/ssl/key.pem)"

Score
CVSS:3.0/AV:N/AC:L/PR:L/UI:R/S:U/C:L/I:N/A:N(3.5)
Recommendation

Remove or disable detailed logging of sensitive data in production. Guard debug logs behind an explicit development flag or logging level like in:

private log = (...args: unknown[]) => {
  if (isDevelopment()) {
    console.debug(`[Request${this.name ? `: ${this.name}` : ''}]`, ...args);
  }
};

Remediation Comment

SOLVED: Several logs were removed and in other cases some logs are only shown in development or staging environments.

The changes were applied in:


Remediation Hash

9.22 DoS via Uncontrolled Memory Allocation

//

Informational

Description

The application processes input that can trigger excessive memory allocation without proper limits or validation. Attackers can exploit this by sending specially crafted requests or large payloads, causing the application to consume excessive resources, become unresponsive, or crash—resulting in a denial of service for legitimate users.

Proof of Concept

The concatB function allocates a Uint8Array whose size is the sum of the lengths of all input arrays. An attacker can supply excessively large messages to sign, verify, or other APIs that call concatB, causing the process to run out of memory and crash.

  • signer-frames/src/lib/noble-ed25519.js

const concatB = (...arrs) => {
  const r = u8n(arrs.reduce((sum, a) => sum + au8(a).length, 0)); // sum can be huge
  let pad = 0;
  arrs.forEach(a => {
    r.set(a, pad);
    pad += a.length;
  });
  return r;
};

In the TEE application await c.req.json<SignerPreGenerationInput>() is used to load the received HTTP body in memory without any size restrictions which could lead to a DoS:

  • src/features/signers/signers.handler.ts

export const derivePublicKeyHandler = async (c: AppContext) => {
	const services = c.get("services");
	const body = await c.req.json<SignerPreGenerationInput>();

Score
Impact: 1
Likelihood: 1
Recommendation

Enforce a maximum allowed message size before concatenation and reject or truncate inputs that exceed this limit.

Enforce also a maximum body size for the received JSON, for example using:

const raw = await c.req.text({ sizeLimit: 1024 * 1024 }); // 1MB
c.req._parsedBody = JSON.parse(raw);

Remediation Comment

SOLVED: Inputs now have a maximum length. This was implemented in https://github.com/Crossmint/open-signer/pull/34/files

Remediation Hash

9.23 Other Documentation Issues

//

Informational

Description
  • The documentation states: "At rest, each device only stores the device share, with the auth share being protected by host application user authentication." However, the auth share is stored in the relay, which is not under the control of the host application.

  • Additionally, other reported issues suggest discrepancies and inaccuracies in the documentation regarding system behavior.


Score
Impact: 1
Likelihood: 1
Recommendation

It's recommended to update the documentation with the information shared in this report to keep an up to date documentation.

Remediation Comment

SOLVED: Documentation issues have been solved.

9.24 Potential Production DoS Bug

//

Informational

Description

A bug in the code was identified that prevents the correct verification of the origin of incoming requests in production environments. Instead of performing the check, an error is immediately raised.

Proof of Concept

The function validateIframeRequest from ncs.controller.ts is throwing an UnauthorizedException if running in production:

const validateIframeRequest = (req: NextApiRequest): void => {
    if (isProduction()) {
        throw new UnauthorizedException();
    }

    if (req.headers.origin !== env.NCS_IFRAME_URL) {
        throw new UnauthorizedException(`This endpoint can only be called from Crossmint's signer iframe!`);
    }
};

This function with the function isProduction() is effectively disabling most endpoints of ncs.controller.ts: /start-onboarding, /complete-onboarding, /derive-public-key, /:devi`ceId/key-shares

Moreover, the function is comparing also the received the origin. This security measure is easy to bypass sending the request from the backend instead of from a browser just setting the Origin header, however, it's better than nothing to enhance the security and prevention of attacks.

Score
Impact: 1
Likelihood: 1
Recommendation

The pentester understands that this is a security measure before going into production, however, it's important to reflect this issue in the report as it could make the production release to fail.

Remediation Comment

FUTURE RELEASE: These restrictions will be removed once the product is released into production.

9.25 Too Privileged ACCESS_SECRET

//

Informational

Description

Using a simple shared secret in an HTTP header authentication mechanism is kind of insecure because it lacks proper cryptographic guarantees. Without TLS, the secret could be intercepted in transit, and even with TLS, if reused across sessions or clients, it becomes trivially replayable or leak-prone via logs, browser plugins, or proxy caches among others. There's no client identity validation, no freshness, and no protection against man-in-the-middle or replay attacks, making it easier for attackers to impersonate either party once the secret is known.

Score
Impact: 1
Likelihood: 1
Recommendation

The access to the TEE application is authorized by a simple secret stored in both the Relay app and the TEE app. Moreover, this secret must be sent in every request from the Relay to the TEE making is more susceptible to MitM attacks.

Of course, using HTTPS in the communication and protecting properly the secret it shouldn't be possible to steal it. However, this secret could allow an attacker to impersonate anyone to the TEE, which are too many privileges for a secret that is "too much exposed" from a security perspective.

It's highly recommended to use other mutual authentication algorithms like mTLS (Mutual TLS authentication).

This issue is reported as informational because at the moment there is no direct way to steal the secret without access to the source code or the environments, but it's however highly recommended to improve the authentication to reduce risks.

Remediation Comment

FUTURE RELEASE: The issue was accepted and the team will be considering adding mTLS in the future

9.26 Deterministic generation of Master Keys

//

Informational

Description

Crossmint trust is based on the fact that Crossmint cannot access the master keys of the users because as soon as it's generated it's delivered and removed from Crossmint infrastructure as Crossmint will only sore the authShare and will never asks the client for the deviceShare.

However, it was discovered that Crossmint can actually recover previously generated master keys.

Proof of Concept

The following code is the one used to generate a new master key:

private async deriveMasterSecret(
		signerId: string,
		authId: string,
	): Promise<Uint8Array> {
		const info = new TextEncoder().encode(
			JSON.stringify({
				signer_id: signerId,
				auth_id: authId,
				version: "1",
			}),
		);

		const derivationSecret = await crypto.subtle.exportKey(
			"pkcs8",
			this.identityKey.privateKey,
		);

		const keyMaterial = await crypto.subtle.importKey(
			"raw",
			derivationSecret,
			{ name: "HKDF" },
			false,
			["deriveBits"],
		);

		const derivedBits = await crypto.subtle.deriveBits(
			{
				name: "HKDF",
				hash: this.HASH_ALGORITHM,
				salt: new Uint8Array(32),
				info,
			},
			keyMaterial,
			256,
		);

		return new Uint8Array(derivedBits);
	}

As it's possible to observe, this code is deterministic and the master secret generated is only based on the private key, the authId and the signerId. All this data can be recovered by Crossmint in the future allowing Crossmint to regenerate master keys.

Note that the used salt to generate the master key is just zeros.

Score
Impact: 1
Likelihood: 1
Recommendation

It's recommended to use a truly random value when generating the master keys to ensure Crossmint cannot recover the master key.

Remediation Comment

RISK ACCEPTED: After talking to the Crossmint team, this feature is expected and necessary for the platform. The private key used within the key derivation is only accessible from the TEE, and any malicious code updates to export the private key would be publicly auditable onchain along with all other TEE code upgrades.

Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.

© Halborn 2025. All rights reserved.