mirror of
https://github.com/httptoolkit/frida-interception-and-unpinning.git
synced 2026-09-14 05:02:11 +02:00
353 lines
15 KiB
JavaScript
353 lines
15 KiB
JavaScript
/**
|
|
* In some cases, proxy configuration by itself won't work. This notably includes Flutter apps (which ignore
|
|
* system/JVM configuration entirely) and plausibly other apps intentionally ignoring proxies. To handle that
|
|
* we hook native connect() calls directly, to redirect traffic on all ports to the target.
|
|
*
|
|
* This handles all attempts to connect an outgoing socket, and for all TCP connections opened it will
|
|
* manually replace the connect() parameters so that the socket connects to the proxy instead of the
|
|
* 'real' destination.
|
|
*
|
|
* This doesn't help with certificate trust (you still need some kind of certificate setup) but it does ensure
|
|
* the proxy receives all connections (and so will see if connections don't trust its CA). It's still useful
|
|
* to do proxy config alongside this, as applications may behave a little more 'correctly' if they're aware
|
|
* they're using a proxy rather than doing so unknowingly.
|
|
*
|
|
* Source available at https://github.com/httptoolkit/frida-interception-and-unpinning/
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
* SPDX-FileCopyrightText: Tim Perry <tim@httptoolkit.com>
|
|
*/
|
|
|
|
(() => {
|
|
const PROXY_HOST_IPv4_BYTES = PROXY_HOST.split('.').map(part => parseInt(part, 10));
|
|
const IPv6_MAPPING_PREFIX_BYTES = [0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xff, 0xff];
|
|
const PROXY_HOST_IPv6_BYTES = IPv6_MAPPING_PREFIX_BYTES.concat(PROXY_HOST_IPv4_BYTES);
|
|
|
|
// Flags for fcntl():
|
|
const F_GETFL = 3;
|
|
const F_SETFL = 4;
|
|
const O_NONBLOCK = (Process.platform === 'darwin')
|
|
? 4
|
|
: 2048; // Linux/Android
|
|
|
|
const ECONNREFUSED = (Process.platform === 'darwin')
|
|
? 61
|
|
: 111; // Linux/Android
|
|
|
|
let fcntl, send, recv, poll, conn;
|
|
try {
|
|
const systemModules = [
|
|
'libc.so', // Android
|
|
'libc.so.6', // Linux
|
|
'libsystem_c.dylib', // iOS
|
|
'libsystem_kernel.dylib' // iOS (syscall wrappers, e.g. fcntl)
|
|
].map((name) => Process.findModuleByName(name))
|
|
.filter((mod) => mod !== null);
|
|
|
|
if (systemModules.length === 0) throw new Error("Could not find any libc/libsystem module");
|
|
|
|
const resolveExport = (name) => {
|
|
for (const mod of systemModules) {
|
|
const addr = mod.findExportByName(name);
|
|
if (addr) return addr;
|
|
}
|
|
throw new Error(`Could not resolve export '${name}' in system modules`);
|
|
};
|
|
|
|
fcntl = new NativeFunction(resolveExport('fcntl'), 'int', ['int', 'int', 'int']);
|
|
send = new NativeFunction(resolveExport('send'), 'ssize_t', ['int', 'pointer', 'size_t', 'int']);
|
|
recv = new NativeFunction(resolveExport('recv'), 'ssize_t', ['int', 'pointer', 'size_t', 'int']);
|
|
poll = new NativeFunction(resolveExport('poll'), 'int', ['pointer', 'ulong', 'int']);
|
|
|
|
conn = resolveExport('connect')
|
|
} catch (e) {
|
|
console.error("Failed to set up native hooks:", e.message);
|
|
console.warn('Could not initialize system functions to to hook raw traffic');
|
|
return;
|
|
}
|
|
|
|
Interceptor.attach(conn, {
|
|
onEnter(args) {
|
|
const fd = this.sockFd = args[0].toInt32();
|
|
const sockType = Socket.type(fd);
|
|
|
|
const addrPtr = ptr(args[1]);
|
|
const addrLen = args[2].toInt32();
|
|
|
|
const isTCP = sockType === 'tcp' || sockType === 'tcp6';
|
|
const isUDP = sockType === 'udp' || sockType === 'udp6';
|
|
const isIPv6 = sockType === 'tcp6' || sockType === 'udp6';
|
|
|
|
if (isTCP || isUDP) {
|
|
if (addrLen < (isIPv6 ? 24 : 8)) {
|
|
if (DEBUG_MODE) {
|
|
console.debug(`Ignoring ${sockType} connection with a ${addrLen}-byte address`);
|
|
}
|
|
this.state = 'ignored';
|
|
return;
|
|
}
|
|
|
|
const addrData = addrPtr.readByteArray(addrLen);
|
|
const portAddrBytes = new DataView(addrData.slice(2, 4));
|
|
const port = portAddrBytes.getUint16(0, false); // Big endian!
|
|
|
|
const shouldBeIgnored = IGNORED_NON_HTTP_PORTS.includes(port);
|
|
const shouldBeBlocked = BLOCK_HTTP3 && !shouldBeIgnored && isUDP && port === 443;
|
|
|
|
// N.b for now we only support TCP interception - UDP direct should be doable,
|
|
// but SOCKS5 UDP would require a whole different flow. Rarely relevant, especially
|
|
// if you're blocking HTTP/3.
|
|
const shouldBeIntercepted = isTCP && !shouldBeIgnored && !shouldBeBlocked;
|
|
|
|
const hostBytes = isIPv6
|
|
// 16 bytes offset by 8 (2 for family, 2 for port, 4 for flowinfo):
|
|
? new Uint8Array(addrData.slice(8, 8 + 16))
|
|
// 4 bytes, offset by 4 (2 for family, 2 for port)
|
|
: new Uint8Array(addrData.slice(4, 4 + 4));
|
|
|
|
const isIntercepted = port === PROXY_PORT && areArraysEqual(hostBytes,
|
|
isIPv6
|
|
? PROXY_HOST_IPv6_BYTES
|
|
: PROXY_HOST_IPv4_BYTES
|
|
);
|
|
|
|
if (isIntercepted) return;
|
|
|
|
if (shouldBeBlocked) {
|
|
if (isIPv6) {
|
|
// Skip 8 bytes: 2 family, 2 port, 4 flowinfo, then write :: (all 0s)
|
|
for (let i = 0; i < 16; i++) {
|
|
addrPtr.add(8 + i).writeU8(0);
|
|
}
|
|
} else {
|
|
// Skip 4 bytes: 2 family, 2 port, then write 0.0.0.0
|
|
addrPtr.add(4).writeU32(0);
|
|
}
|
|
|
|
if (DEBUG_MODE) {
|
|
console.debug(`Blocking QUIC connection to ${
|
|
getReadableAddress(hostBytes, isIPv6)}:${port}`);
|
|
}
|
|
this.state = 'Blocked';
|
|
} else if (shouldBeIntercepted) {
|
|
// Otherwise, it's an unintercepted connection that should be captured:
|
|
this.state = 'intercepting';
|
|
|
|
// For SOCKS, we preserve the original destionation to use in the SOCKS handshake later
|
|
// and we temporarily set the socket to blocking mode to do the handshake itself.
|
|
if (PROXY_SUPPORTS_SOCKS5) {
|
|
this.originalDestination = { host: hostBytes, port, isIPv6 };
|
|
this.originalFlags = fcntl(this.sockFd, F_GETFL, 0);
|
|
this.isNonBlocking = (this.originalFlags & O_NONBLOCK) !== 0;
|
|
if (this.isNonBlocking) {
|
|
fcntl(this.sockFd, F_SETFL, this.originalFlags & ~O_NONBLOCK);
|
|
}
|
|
}
|
|
|
|
if (DEBUG_MODE) {
|
|
console.log(`Manually intercepting ${sockType} connection to ${
|
|
getReadableAddress(hostBytes, isIPv6)}:${port}`);
|
|
}
|
|
|
|
// Overwrite the port with the proxy port:
|
|
portAddrBytes.setUint16(0, PROXY_PORT, false); // Big endian
|
|
addrPtr.add(2).writeByteArray(portAddrBytes.buffer);
|
|
|
|
// Overwrite the address with the proxy address:
|
|
if (isIPv6) {
|
|
// Skip 8 bytes: 2 family, 2 port, 4 flowinfo
|
|
addrPtr.add(8).writeByteArray(PROXY_HOST_IPv6_BYTES);
|
|
} else {
|
|
// Skip 4 bytes: 2 family, 2 port
|
|
addrPtr.add(4).writeByteArray(PROXY_HOST_IPv4_BYTES);
|
|
}
|
|
} else {
|
|
// Explicitly being left alone
|
|
if (DEBUG_MODE) {
|
|
console.debug(`Allowing unintercepted ${sockType} connection to port ${port}`);
|
|
}
|
|
this.state = 'ignored';
|
|
}
|
|
} else {
|
|
// Should just be unix domain sockets - UDP & TCP are covered above
|
|
if (DEBUG_MODE) console.log(`Ignoring ${sockType} connection`);
|
|
this.state = 'ignored';
|
|
}
|
|
},
|
|
onLeave: function (retval) {
|
|
if (this.state === 'ignored') return;
|
|
|
|
if (this.state === 'intercepting' && PROXY_SUPPORTS_SOCKS5) {
|
|
const connectSuccess = retval.toInt32() === 0;
|
|
const { host, port, isIPv6 } = this.originalDestination;
|
|
|
|
let handshakeSuccess = false;
|
|
try {
|
|
if (connectSuccess) {
|
|
handshakeSuccess = performSocksHandshake(this.sockFd, host, port, isIPv6);
|
|
} else {
|
|
console.error(`SOCKS: Failed to connect to proxy at ${PROXY_HOST}:${PROXY_PORT}`);
|
|
}
|
|
} catch (e) {
|
|
console.error(`SOCKS: Handshake failed for fd ${this.sockFd}: ${e}`);
|
|
} finally {
|
|
if (this.isNonBlocking) {
|
|
fcntl(this.sockFd, F_SETFL, this.originalFlags);
|
|
}
|
|
|
|
if (!handshakeSuccess) this.errno = ECONNREFUSED;
|
|
retval.replace(handshakeSuccess ? 0 : -1);
|
|
}
|
|
|
|
if (DEBUG_MODE) {
|
|
console.debug(handshakeSuccess
|
|
? `SOCKS redirect successful for fd ${this.sockFd} to ${
|
|
getReadableAddress(host, isIPv6)}:${port}`
|
|
: `SOCKS redirect FAILED for fd ${this.sockFd}`
|
|
);
|
|
}
|
|
} else if (DEBUG_MODE) {
|
|
const fd = this.sockFd;
|
|
const sockType = Socket.type(fd);
|
|
const address = Socket.peerAddress(fd);
|
|
console.debug(
|
|
`${this.state} ${sockType} fd ${fd} to ${JSON.stringify(address)} (${retval.toInt32()})`
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
console.log(`== Redirecting ${
|
|
IGNORED_NON_HTTP_PORTS.length === 0
|
|
? 'all'
|
|
: 'all unrecognized'
|
|
} TCP connections to ${PROXY_HOST}:${PROXY_PORT} ==`);
|
|
|
|
const isIPv4Mapped = (/** @type {Uint8Array} */ hostBytes) =>
|
|
hostBytes.length === 16 &&
|
|
hostBytes.slice(0, 10).every(b => b === 0) &&
|
|
hostBytes.slice(10, 12).every(b => b === 255);
|
|
|
|
const getReadableAddress = (
|
|
/** @type {Uint8Array} */ hostBytes,
|
|
/** @type {boolean} */ isIPv6
|
|
) => {
|
|
if (!isIPv6) {
|
|
// Return simple a.b.c.d IPv4 format:
|
|
return [...hostBytes].map(x => x.toString()).join('.');
|
|
}
|
|
|
|
if (isIPv4Mapped(hostBytes)) {
|
|
// IPv4-mapped IPv6 address - print as IPv4 for readability
|
|
return '::ffff:'+[...hostBytes.slice(12)].map(x => x.toString()).join('.');
|
|
}
|
|
|
|
else {
|
|
// Real IPv6:
|
|
return `[${[...hostBytes].map(x => x.toString(16)).join(':')}]`;
|
|
}
|
|
};
|
|
|
|
const areArraysEqual = (arrayA, arrayB) => {
|
|
if (arrayA.length !== arrayB.length) return false;
|
|
return arrayA.every((x, i) => arrayB[i] === x);
|
|
};
|
|
|
|
const SOCKS_TIMEOUT_MS = 2000;
|
|
const POLLIN = 0x1;
|
|
|
|
const waitForReadable = (sockfd, timeoutMs) => {
|
|
const pollFd = Memory.alloc(8);
|
|
pollFd.writeInt(sockfd);
|
|
pollFd.add(4).writeU16(POLLIN);
|
|
pollFd.add(6).writeU16(0);
|
|
return poll(pollFd, 1, timeoutMs) === 1;
|
|
};
|
|
|
|
// recv() repeatedly up to the data length we need (or timeout/hangup)
|
|
const recvAll = (sockfd, buffer, length) => {
|
|
let received = 0;
|
|
const deadline = Date.now() + SOCKS_TIMEOUT_MS;
|
|
while (received < length) {
|
|
const remaining = deadline - Date.now();
|
|
if (remaining <= 0 || !waitForReadable(sockfd, remaining)) return false;
|
|
|
|
const read = recv(sockfd, buffer.add(received), length - received, 0).toNumber();
|
|
if (read <= 0) return false; // Error, or the proxy hung up on us
|
|
received += read;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
function performSocksHandshake(sockfd, targetHostBytes, targetPort, isIPv6) {
|
|
const hello = Memory.alloc(3).writeByteArray([0x05, 0x01, 0x00]);
|
|
if (send(sockfd, hello, 3, 0).toNumber() !== 3) {
|
|
console.error("SOCKS: Failed to send hello");
|
|
return false;
|
|
}
|
|
|
|
const response = Memory.alloc(2);
|
|
if (!recvAll(sockfd, response, 2)) {
|
|
console.error("SOCKS: No auth method reply from the proxy");
|
|
return false;
|
|
}
|
|
|
|
if (response.readU8() !== 0x05 || response.add(1).readU8() !== 0x00) {
|
|
console.error("SOCKS: Server rejected auth method");
|
|
return false;
|
|
}
|
|
|
|
let req = [0x05, 0x01, 0x00]; // VER, CMD(CONNECT), RSV
|
|
|
|
// Map IPv6-mapped-IPv4 back to simple IPv4:
|
|
if (isIPv6 && isIPv4Mapped(targetHostBytes)) {
|
|
targetHostBytes = targetHostBytes.slice(12);
|
|
isIPv6 = false;
|
|
}
|
|
|
|
if (isIPv6) {
|
|
req.push(0x04); // ATYP: IPv6
|
|
} else { // IPv4
|
|
req.push(0x01); // ATYP: IPv4
|
|
}
|
|
|
|
req.push(...targetHostBytes, (targetPort >> 8) & 0xff, targetPort & 0xff);
|
|
const reqBuf = Memory.alloc(req.length).writeByteArray(req);
|
|
|
|
if (send(sockfd, reqBuf, req.length, 0).toNumber() !== req.length) {
|
|
console.error("SOCKS: Failed to send connection request");
|
|
return false;
|
|
}
|
|
|
|
const replyHeader = Memory.alloc(4);
|
|
if (!recvAll(sockfd, replyHeader, 4)) {
|
|
console.error("SOCKS: No connection reply from the proxy");
|
|
return false;
|
|
}
|
|
|
|
const replyCode = replyHeader.add(1).readU8();
|
|
if (replyCode !== 0x00) {
|
|
console.error(`SOCKS: Server returned error code ${replyCode}`);
|
|
return false;
|
|
}
|
|
|
|
// Reply ends with an address, which we need to consume to avoid leaking into the
|
|
// normal app traffic afterwards
|
|
const atyp = replyHeader.add(3).readU8();
|
|
const addressLength = atyp === 0x01 ? 4 + 2 // IPv4 + port
|
|
: atyp === 0x04 ? 16 + 2 // IPv6 + port
|
|
: 0;
|
|
|
|
if (!addressLength) {
|
|
// Hostnames (ATYP 3) are legal but rare & HTK never uses them, just reject
|
|
console.error(`SOCKS: Server replied with an unsupported address type ${atyp}`);
|
|
return false;
|
|
}
|
|
|
|
if (!recvAll(sockfd, Memory.alloc(addressLength), addressLength)) {
|
|
console.error("SOCKS: Failed to read the bound address");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
})(); |