Prepared by:
HALBORN
Last Updated 08/13/2025
Date of Engagement: June 4th, 2025 - July 3rd, 2025
100% of all REPORTED Findings have been addressed
All findings
26
Critical
0
High
7
Medium
8
Low
6
Informational
5
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.
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.
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
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.
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 analysis | Risk level | Remediation Date |
---|---|---|
Host MitM and Insecure Default Target Origin | High | Risk Accepted - 07/10/2025 |
Environment Parameter Tampering via URL Override | High | Solved - 07/01/2025 |
Lack of Content Security Policy and Frame Ancestor Protection | High | Solved - 07/14/2025 |
Potential Arbitrary JavaScript Injection | High | Solved - 07/11/2025 |
Potential Authentication Bypass with ACCESS_SECRET Unset | High | Solved - 07/10/2025 |
Log Injection & use of x-forwarded-for | High | Solved - 07/11/2025 |
Excessive Exposure of Key Shares | High | Solved - 07/10/2025 |
Insecure storage of cryptographic keys in localStorage | Medium | Solved - 07/10/2025 |
Hardcoded and Weak Secret | Medium | Solved - 07/15/2025 |
Potential XSS & Potential Reverse-Tabnabbing | Medium | Solved - 07/11/2025 |
IDOR in Public Key Derivation | Medium | Not Applicable - 07/11/2025 |
Lack of Rate Limiting | Medium | Solved - 07/14/2025 |
Attestation Response Replay Attack | Medium | Solved - 07/18/2025 |
Potential Timing-Attack on Shared Secret Comparison | Medium | Solved - 07/09/2025 |
Lack of Message Origin Validation | Medium | Risk Accepted - 07/11/2025 |
Master Keys Not Explicitly Removed from Memory | Low | Risk Accepted - 07/14/2025 |
Too Much Data Shared in Attestation | Low | Not Applicable - 07/11/2025 |
Missing event.source Validation Allows Same-Origin Message Spoofing | Low | Solved - 07/11/2025 |
Weak Randomness | Low | Solved - 07/15/2025 |
Excessive Error Information Exposed to Caller | Low | Solved - 07/11/2025 |
Sensitive Data Logged to Console | Low | Solved - 07/15/2025 |
DoS via Uncontrolled Memory Allocation | Informational | Solved - 07/16/2025 |
Other Documentation Issues | Informational | Solved - 07/18/2025 |
Potential Production DoS Bug | Informational | Future Release - 07/15/2025 |
Too Privileged ACCESS_SECRET | Informational | Future Release - 07/15/2025 |
Deterministic generation of Master Keys | Informational | Risk Accepted - 06/24/2025 |
//
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.
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,
});
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.
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.
//
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.
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;
}
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;
}
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.
//
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.
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.
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.
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.
//
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.
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:
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).
The value is blindly interpolated into combinedInjectedJs
.
The resulting string is injected as injectedJavaScriptBeforeContentLoaded
, which the WebView executes with full DOM access before any page content loads.
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.
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.
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.
//
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.
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.
It's recommended to make createMiddleware
throw an error if c.get("env").ACCESS_SECRET
is undefined or empty.
SOLVED: This issues was solved in https://github.com/Crossmint/open-signer/pull/29/files
//
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.
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();
});
};
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.
SOLVED: This issue was solved by removing the use of X-Forwarded-For in https://github.com/Crossmint/open-signer/pull/30
//
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.
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.
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).
SOLVED:
First, the architecture simplification where we remove key splitting and simply encrypt the master secret to end user devices. With this change no master secret key material is stored on the end user device at rest.
TEE and Frame: https://github.com/Crossmint/open-signer/pull/14
Crossmint Relay: https://github.com/Paella-Labs/crossbit-main/pull/19530
Second, making the iframe / client decryption key non extractable, preventing XSS, malicious signers.crossmint.com
updates, or other iframe related exploits from obtaining it. https://github.com/Crossmint/open-signer/pull/15
Finally clearing stale entries from the iframe's encrypted master secret cache on interval https://github.com/Crossmint/open-signer/pull/19
//
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.
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));
}
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.
SOLVED:
First, the architecture simplification where we remove key splitting and simply encrypt the master secret to end user devices. With this change no master secret key material is stored on the end user device at rest.
TEE and Frame: https://github.com/Crossmint/open-signer/pull/14
Crossmint Relay: https://github.com/Paella-Labs/crossbit-main/pull/19530
Second, making the iframe / client decryption key non extractable, preventing XSS, malicious signers.crossmint.com
updates, or other iframe related exploits from obtaining it. https://github.com/Crossmint/open-signer/pull/15
Finally clearing stale entries from the iframe's encrypted master secret cache on interval https://github.com/Crossmint/open-signer/pull/19
//
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.
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
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.
SOLVED: The hardcoded secret was removed from the code at https://github.com/Paella-Labs/crossbit-main/pull/19635
//
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.
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");
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");
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
//
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.
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 });
};
It's recommended to only allow to generate a public key for the current user and not for other users.
NOT APPLICABLE: This issue isn't aplicable because the value of req.user
is being assigned in assertJwtUser
from the JWT.
//
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.
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);
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 ...
SOLVED: A more restrictive rate limit was applied in https://github.com/Paella-Labs/crossbit-main/pull/19622
//
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.
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"),
});
};
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.
SOLVED: The timestamp was added in the PR https://github.com/Crossmint/open-signer/pull/39
//
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.
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();
});
};
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();
SOLVED: Solved in https://github.com/Crossmint/open-signer/pull/16
//
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.
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;
}
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.
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.
//
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.
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.
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.
RISK ACCEPTED: The Crossmint team indicated that they will accept the risk.
//
The attestation endpoint of the Relay and TEE might be sharing too much information which could be omited.
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.
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
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.
//
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.
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");
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);
}
};
SOLVED: Fixed in https://github.com/Crossmint/crossmint-sdk/pull/1282/files verifying the origin of the message.
//
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.
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);
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("");
}
SOLVED: A secure random number generator is now used.
//
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.
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;
}
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' };
}
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.
//
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.
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)"
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);
}
};
SOLVED: Several logs were removed and in other cases some logs are only shown in development or staging environments.
The changes were applied in:
//
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.
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>();
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);
SOLVED: Inputs now have a maximum length. This was implemented in https://github.com/Crossmint/open-signer/pull/34/files
//
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.
It's recommended to update the documentation with the information shared in this report to keep an up to date documentation.
SOLVED: Documentation issues have been solved.
//
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.
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.
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.
FUTURE RELEASE: These restrictions will be removed once the product is released into production.
//
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.
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.
FUTURE RELEASE: The issue was accepted and the team will be considering adding mTLS in the future
//
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.
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.
It's recommended to use a truly random value when generating the master keys to ensure Crossmint cannot recover the master key.
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.
// Download the full report
Non-Custodial Signer Solution
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed