Skip to content

Commit

Permalink
feat: add websocket support for c2 detection (#28782)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**


This pull request adds WebSocket support to the MetaMask extension's
phishing detection functionality. Scammers have started using WebSocket
connections for command-and-control (C2) operations to bypass
traditional HTTP-based phishing detection. This PR allows the extension
to intercept and block WebSocket handshake requests (`ws://` and
`wss://`) in addition to HTTP/HTTPS requests.

The key changes include:
1. Adding WebSocket schemes (`ws://*/*` and `wss://*/*`) to the `urls`
filter in `background.js`.
2. Updating the `manifest.json` to include WebSocket permissions in the
`host_permissions` field.

This ensures that malicious WebSocket connections can be detected and
blocked.


<!--
Write a short description of the changes included in this pull request,
also include relevant motivation and context. Have in mind the following
questions:
1. What is the reason for the change?
2. What is the improvement/solution?
-->

[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/28782?quickstart=1)

## **Related issues**

Fixes: MetaMask/MetaMask-planning#3788

## **Manual testing steps**

1. Navigate to `example.com`
2. Initiate a WebSocket connection to a known safe domain (e.g.,
`wss://example.com`) and verify it works as expected by going to the
`console` via right clicking and hitting inspect. Then type into the
console `new WebSocket("https://example.com/")`
3. Attempt a WebSocket connection to a domain flagged as phishing, and
verify the connection is blocked and appropriate warnings are displayed
by going to the `console` via right clicking and hitting inspect. Then
type into the console `new WebSocket("https://walietconnectapi.com/")`


## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

No support for detecting WebSocket phishing connections.

---

### **After**

WebSocket phishing connections are detected and blocked during the
handshake phase.


<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/develop/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
AugmentedMode authored Dec 11, 2024
1 parent 5de2173 commit e0f6575
Show file tree
Hide file tree
Showing 7 changed files with 136 additions and 14 deletions.
2 changes: 2 additions & 0 deletions app/manifest/v2/_base.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@
"clipboardWrite",
"http://*/*",
"https://*/*",
"ws://*/*",
"wss://*/*",
"activeTab",
"webRequest",
"webRequestBlocking",
Expand Down
4 changes: 3 additions & 1 deletion app/manifest/v3/_base.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@
"http://localhost:8545/",
"file://*/*",
"http://*/*",
"https://*/*"
"https://*/*",
"ws://*/*",
"wss://*/*"
],
"icons": {
"16": "images/icon-16.png",
Expand Down
2 changes: 1 addition & 1 deletion app/scripts/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function maybeDetectPhishing(theController) {
return {};
},
{
urls: ['http://*/*', 'https://*/*'],
urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'],
},
isManifestV2 ? ['blocking'] : [],
);
Expand Down
3 changes: 2 additions & 1 deletion privacy-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,6 @@
"unresponsive-rpc.test",
"unresponsive-rpc.url",
"user-storage.api.cx.metamask.io",
"www.4byte.directory"
"www.4byte.directory",
"verify.walletconnect.com"
]
44 changes: 44 additions & 0 deletions test/e2e/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const BigNumber = require('bignumber.js');
const mockttp = require('mockttp');
const detectPort = require('detect-port');
const { difference } = require('lodash');
const WebSocket = require('ws');
const createStaticServer = require('../../development/create-static-server');
const { setupMocking } = require('./mock-e2e');
const { Ganache } = require('./seeder/ganache');
Expand Down Expand Up @@ -640,6 +641,48 @@ async function unlockWallet(
}
}

/**
* Simulates a WebSocket connection by executing a script in the browser context.
*
* @param {WebDriver} driver - The WebDriver instance.
* @param {string} hostname - The hostname to connect to.
*/
async function createWebSocketConnection(driver, hostname) {
try {
await driver.executeScript(async (wsHostname) => {
const url = `ws://${wsHostname}:8000`;

const socket = new WebSocket(url);

socket.onopen = () => {
console.log('WebSocket connection opened');
socket.send('Hello, server!');
};

socket.onerror = (error) => {
console.error(
'WebSocket error:',
error.message || 'Connection blocked',
);
};

socket.onmessage = (event) => {
console.log('Message received from server:', event.data);
};

socket.onclose = () => {
console.log('WebSocket connection closed');
};
}, hostname);
} catch (error) {
console.error(
`Failed to execute WebSocket connection script for ws://${hostname}:8081`,
error,
);
throw error;
}
}

const logInWithBalanceValidation = async (driver, ganacheServer) => {
await unlockWallet(driver);
// Wait for balance to load
Expand Down Expand Up @@ -975,4 +1018,5 @@ module.exports = {
tempToggleSettingRedesignedTransactionConfirmations,
openMenuSafe,
sentryRegEx,
createWebSocketConnection,
};
19 changes: 9 additions & 10 deletions test/e2e/tests/phishing-controller/mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ const {
const lastUpdated = 1;
const defaultHotlist = { data: [] };
const defaultC2DomainBlocklist = {
recentlyAdded: [],
recentlyAdded: [
'33c8e026e76cea2df82322428554c932961cd80080fa379454350d7f13371f36', // hash for malicious.localhost
],
recentlyRemoved: [],
lastFetchedAt: '2024-08-27T15:30:45Z',
};
Expand Down Expand Up @@ -95,15 +97,12 @@ async function setupPhishingDetectionMocks(
};
});

await mockServer
.forGet(C2_DOMAIN_BLOCKLIST_URL)
.withQuery({ timestamp: '2024-08-27T15:30:45Z' })
.thenCallback(() => {
return {
statusCode: 200,
json: defaultC2DomainBlocklist,
};
});
await mockServer.forGet(C2_DOMAIN_BLOCKLIST_URL).thenCallback(() => {
return {
statusCode: 200,
json: defaultC2DomainBlocklist,
};
});

await mockServer
.forGet('https://github.com/MetaMask/eth-phishing-detect/issues/new')
Expand Down
76 changes: 75 additions & 1 deletion test/e2e/tests/phishing-controller/phishing-detection.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ const { strict: assert } = require('assert');
const { createServer } = require('node:http');
const { createDeferredPromise } = require('@metamask/utils');
const { until } = require('selenium-webdriver');

const {
defaultGanacheOptions,
withFixtures,
openDapp,
unlockWallet,
WINDOW_TITLES,
createWebSocketConnection,
} = require('../../helpers');
const FixtureBuilder = require('../../fixture-builder');
const {
Expand Down Expand Up @@ -315,6 +315,80 @@ describe('Phishing Detection', function () {
);
});

it('should block a website that makes a websocket connection to a malicious command and control server', async function () {
const testPageURL = 'http://localhost:8080';
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
ganacheOptions: defaultGanacheOptions,
title: this.test.fullTitle(),
testSpecificMock: async (mockServer) => {
await mockServer.forAnyWebSocket().thenEcho();
await setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
});
},
dapp: true,
},
async ({ driver }) => {
await unlockWallet(driver);

await driver.openNewPage(testPageURL);

await createWebSocketConnection(driver, 'malicious.localhost');

await driver.switchToWindowWithTitle(
'MetaMask Phishing Detection',
10000,
);

await driver.waitForSelector({
testId: 'unsafe-continue-loaded',
});

await driver.clickElement({
text: 'Back to safety',
});

const currentUrl = await driver.getCurrentUrl();
const expectedPortfolioUrl = `https://portfolio.metamask.io/?metamaskEntry=phishing_page_portfolio_button`;

assert.equal(currentUrl, expectedPortfolioUrl);
},
);
});

it('should not block a website that makes a safe WebSocket connection', async function () {
const testPageURL = 'http://localhost:8080/';
await withFixtures(
{
fixtures: new FixtureBuilder().build(),
ganacheOptions: defaultGanacheOptions,
title: this.test.fullTitle(),
testSpecificMock: async (mockServer) => {
await mockServer.forAnyWebSocket().thenEcho();
await setupPhishingDetectionMocks(mockServer, {
blockProvider: BlockProvider.MetaMask,
});
},
dapp: true,
},
async ({ driver }) => {
await unlockWallet(driver);

await driver.openNewPage(testPageURL);

await createWebSocketConnection(driver, 'safe.localhost');

await driver.wait(until.titleIs(WINDOW_TITLES.TestDApp), 10000);

const currentUrl = await driver.getCurrentUrl();

assert.equal(currentUrl, testPageURL);
},
);
});

describe('Phishing redirect protections', function () {
/**
* Status codes 305 (via Location header) and 306 (Set-Proxy) header do not
Expand Down

0 comments on commit e0f6575

Please sign in to comment.