From c2ad29700597a2b5dbfa43a2d58f1cbf53ddeb6c Mon Sep 17 00:00:00 2001 From: emanuele-f Date: Sun, 6 Feb 2022 00:21:32 +0100 Subject: [PATCH] Initial mitmproxy plugin implementation The pcapdroid_mitm.py plugin allows PCAPdroid to receive the decrypted data from mitmproxy and display it into the GUI (currently limited to the HTTP request) --- .../remote_capture/CaptureService.java | 22 ++ .../remote_capture/ConnectionsRegister.java | 8 + .../remote_capture/PlaintextReceiver.java | 216 ++++++++++++++++++ .../model/ConnectionDescriptor.java | 4 +- app/src/main/jni/core/capture_vpn.c | 37 ++- app/src/main/jni/core/jni_impl.c | 4 +- app/src/main/jni/core/pcapdroid.c | 1 + app/src/main/jni/core/pcapdroid.h | 3 +- tools/pcapdroid_mitm.py | 76 ++++++ 9 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/emanuelef/remote_capture/PlaintextReceiver.java create mode 100755 tools/pcapdroid_mitm.py diff --git a/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java b/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java index e8f204ba..d0bed4e5 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java +++ b/app/src/main/java/com/emanuelef/remote_capture/CaptureService.java @@ -74,6 +74,7 @@ import com.emanuelef.remote_capture.pcap_dump.HTTPServer; import com.emanuelef.remote_capture.interfaces.PcapDumper; import com.emanuelef.remote_capture.pcap_dump.UDPDumper; +import java.io.File; import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; @@ -100,6 +101,7 @@ public class CaptureService extends VpnService implements Runnable { private Thread mBlacklistsUpdateThread; private Thread mConnUpdateThread; private Thread mDumperThread; + private PlaintextReceiver mPlaintextReceiver; private final LinkedBlockingDeque> mPendingUpdates = new LinkedBlockingDeque<>(32); private LinkedBlockingDeque mDumpQueue; private String vpn_ipv4; @@ -288,6 +290,17 @@ public class CaptureService extends VpnService implements Runnable { } } + // adb shell run-as com.emanuelef.remote_capture.debug touch cache/BETA_PLAINTEXT_RECEIVER + if(mSettings.socks5_enabled && (new File(getCacheDir() + "/BETA_PLAINTEXT_RECEIVER")).exists()) { + mPlaintextReceiver = new PlaintextReceiver(); + try { + mPlaintextReceiver.start(); + } catch (IOException e) { + e.printStackTrace(); + mPlaintextReceiver = null; + } + } + if ((mSettings.app_filter != null) && (!mSettings.app_filter.isEmpty())) { try { app_filter_uid = getPackageManager().getApplicationInfo(mSettings.app_filter, 0).uid; @@ -620,6 +633,15 @@ public class CaptureService extends VpnService implements Runnable { mDumperThread = null; mDumper = null; + if(mPlaintextReceiver != null) { + try { + mPlaintextReceiver.stop(); + } catch (IOException e) { + e.printStackTrace(); + } + mPlaintextReceiver = null; + } + if(mParcelFileDescriptor != null) { try { mParcelFileDescriptor.close(); diff --git a/app/src/main/java/com/emanuelef/remote_capture/ConnectionsRegister.java b/app/src/main/java/com/emanuelef/remote_capture/ConnectionsRegister.java index 77505832..72a49cce 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/ConnectionsRegister.java +++ b/app/src/main/java/com/emanuelef/remote_capture/ConnectionsRegister.java @@ -303,6 +303,14 @@ public class ConnectionsRegister { return -1; } + public synchronized @Nullable ConnectionDescriptor getConnById(int incr_id) { + int pos = getConnPositionById(incr_id); + if(pos < 0) + return null; + + return getConn(pos); + } + public synchronized List getAppsStats() { ArrayList rv = new ArrayList<>(mAppsStats.size()); diff --git a/app/src/main/java/com/emanuelef/remote_capture/PlaintextReceiver.java b/app/src/main/java/com/emanuelef/remote_capture/PlaintextReceiver.java new file mode 100644 index 00000000..49d001af --- /dev/null +++ b/app/src/main/java/com/emanuelef/remote_capture/PlaintextReceiver.java @@ -0,0 +1,216 @@ +/* + * This file is part of PCAPdroid. + * + * PCAPdroid is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * PCAPdroid is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with PCAPdroid. If not, see . + * + * Copyright 2022 - Emanuele Faranda + */ + +package com.emanuelef.remote_capture; + +import android.util.Log; +import android.util.LruCache; + +import com.emanuelef.remote_capture.interfaces.ConnectionsListener; +import com.emanuelef.remote_capture.model.ConnectionDescriptor; + +import java.io.DataInputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.NoSuchElementException; +import java.util.StringTokenizer; + +/* An experimental receiver for the TLS decryption plaintext. + * + * A mitmproxy plugin sends TCP messages on port 5750, containing an header and the plaintext. + * + * The header is an ASCII string in the following format: + * "port:payload_type:payload_length\n" + * - port: the TCP local port used by the SOCKS5 client + * - payload_type: http_req|http_rep|ws_climsg|ws_srvmsg + * - payload_length: the payload length in bytes: + * + * The raw payload data follows the header. + */ +public class PlaintextReceiver implements Runnable, ConnectionsListener { + private static final String TAG = "PlaintextReceiver"; + public static final int MAX_PLAINTEXT_LENGTH = 1024; // sync with pcapdroid.h + private ServerSocket mSocket; + private Socket mClient; + private Thread mThread; + private boolean mRunning; + private final ConnectionsRegister mReg; + + // Shared state + private final LruCache mPortToConnId = new LruCache<>(64); + + public PlaintextReceiver() { + mReg = CaptureService.requireConnsRegister(); + } + + public void start() throws IOException { + mSocket = new ServerSocket(); + mSocket.setReuseAddress(true); + mSocket.bind(new InetSocketAddress(5750), 1); + + mRunning = true; + mThread = new Thread(this); + mThread.start(); + + mReg.addListener(this); + } + + public void stop() throws IOException { + mRunning = false; + mReg.removeListener(this); + + // Possibly generate a socket exception on the thread + mSocket.close(); + if(mClient != null) + mClient.close(); + + while((mThread != null) && (mThread.isAlive())) { + try { + Log.d(TAG, "Joining receiver thread..."); + mThread.join(); + } catch (InterruptedException e) { + Log.e(TAG, "Joining receiver thread failed"); + } + } + } + + @Override + public void run() { + while(mRunning) { + try { + mClient = mSocket.accept(); + Log.d(TAG, String.format("Client: %s:%d", mClient.getInetAddress().getHostAddress(), mClient.getPort())); + handleClient(); + } catch (IOException e) { + if(!mRunning) + Log.d(TAG, "Got termination request"); + else + Log.d(TAG, e.getLocalizedMessage()); + } + } + + try { + if(mClient != null) + mClient.close(); + } catch (IOException ignored) {} + mClient = null; + } + + private void handleClient() throws IOException { + try(DataInputStream istream = new DataInputStream(mClient.getInputStream())) { + while(mRunning) { + String payload_type; + int port; + int payload_len; + + // Read the header + String header = istream.readLine(); + StringTokenizer tk = new StringTokenizer(header); + //Log.d(TAG, "[HEADER] " + header); + + try { + // port:payload_type:payload_length\n + String tk_port = tk.nextToken(":"); + payload_type = tk.nextToken(); + String tk_len = tk.nextToken(); + + port = Integer.parseInt(tk_port); + payload_len = Integer.parseInt(tk_len); + } catch (NoSuchElementException | NumberFormatException e) { + Log.w(TAG, "Invalid header"); + return; + } + + if((payload_len <= 0) || (payload_len > 1048576)) { /* max 1 MB */ + Log.w(TAG, "Bad payload length: " + payload_len); + return; + } + + if(payload_type.equals("http_req")) { + byte[] payload = new byte[payload_len]; + istream.readFully(payload); + + //Log.d(TAG, "HTTP_REQUEST [" + payload_len + "]"); + + ConnectionDescriptor conn = getConnByLocalPort(port); + if((conn != null) && (conn.l7proto.equals("TLS")) && (conn.request_plaintext.isEmpty())) { + // NOTE: we are accessing conn concurrently, however request_plaintext is + // never set inline for encrypted flows. + conn.request_plaintext = getPlaintextString(payload); + } + } else + istream.skipBytes(payload_len); // ignore for now + } + } + } + + @Override + public void connectionsChanges(int num_connetions) {} + @Override + public void connectionsRemoved(int start, ConnectionDescriptor[] conns) {} + @Override + public void connectionsUpdated(int[] positions) {} + + @Override + public void connectionsAdded(int start, ConnectionDescriptor[] conns) { + synchronized(mPortToConnId) { + // Save the latest port->ID mapping + for(ConnectionDescriptor conn: conns) + mPortToConnId.put(conn.local_port, conn.incr_id); + } + } + + ConnectionDescriptor getConnByLocalPort(int local_port) { + Integer conn_id; + + synchronized(mPortToConnId) { + conn_id = mPortToConnId.get(local_port); + } + if(conn_id == null) + return null; + + ConnectionDescriptor conn = mReg.getConnById(conn_id); + if((conn == null) || (conn.local_port != local_port)) + return null; + + // success + return conn; + } + + // sync with pcapdroid.c + private boolean is_plaintext(byte c) { + return ((c >= 32) && (c <= 126)) || (c == '\r') || (c == '\n') || (c == '\t'); + } + + private String getPlaintextString(byte []bytes) { + int i = 0; + int limit = Math.min(bytes.length, MAX_PLAINTEXT_LENGTH); + + while(i < limit) { + if(!is_plaintext(bytes[i])) + break; + i++; + } + + return new String(bytes, 0, i, StandardCharsets.US_ASCII); + } +} diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/ConnectionDescriptor.java b/app/src/main/java/com/emanuelef/remote_capture/model/ConnectionDescriptor.java index 417131de..48f276ea 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/model/ConnectionDescriptor.java +++ b/app/src/main/java/com/emanuelef/remote_capture/model/ConnectionDescriptor.java @@ -66,6 +66,7 @@ public class ConnectionDescriptor implements Serializable { public final String dst_ip; public final int src_port; public final int dst_port; + public final int local_port; // in VPN mode, this is the local port of the Internet connection /* Data */ public long first_seen; @@ -94,7 +95,7 @@ public class ConnectionDescriptor implements Serializable { public boolean alerted; public ConnectionDescriptor(int _incr_id, int _ipver, int _ipproto, String _src_ip, String _dst_ip, - int _src_port, int _dst_port, int _uid, int _ifidx, long when) { + int _src_port, int _dst_port, int _local_port, int _uid, int _ifidx, long when) { incr_id = _incr_id; ipver = _ipver; ipproto = _ipproto; @@ -102,6 +103,7 @@ public class ConnectionDescriptor implements Serializable { dst_ip = _dst_ip; src_port = _src_port; dst_port = _dst_port; + local_port = _local_port; uid = _uid; ifidx = _ifidx; first_seen = last_seen = when; diff --git a/app/src/main/jni/core/capture_vpn.c b/app/src/main/jni/core/capture_vpn.c index 0d17da80..f4e0cc60 100644 --- a/app/src/main/jni/core/capture_vpn.c +++ b/app/src/main/jni/core/capture_vpn.c @@ -370,6 +370,13 @@ static void update_conn_status(zdtun_t *zdt, const zdtun_pkt_t *pkt, uint8_t fro /* ******************************************************* */ +// TODO with built-in decryption, only proxy encrypted connections +static bool should_proxy(pcapdroid_t *pd, const zdtun_5tuple_t *tuple) { + return pd->socks5.enabled && (tuple->ipproto == IPPROTO_TCP); +} + +/* ******************************************************* */ + int run_vpn(pcapdroid_t *pd) { zdtun_t *zdt; char buffer[VPN_BUFFER_SIZE]; @@ -519,7 +526,7 @@ int run_vpn(pcapdroid_t *pd) { spoof_dns_reply(pd, conn, &pctx); zdtun_conn_close(zdt, conn, CONN_STATUS_CLOSED); } - } else if(pd->socks5.enabled && (tuple->ipproto == IPPROTO_TCP)) + } else if(should_proxy(pd, tuple)) zdtun_conn_proxy(conn); } @@ -541,10 +548,30 @@ int run_vpn(pcapdroid_t *pd) { pd->num_dropped_connections++; zdtun_conn_close(zdt, conn, CONN_STATUS_ERROR); goto housekeeping; - } else if(data->vpn.fw_pctx) { - // not accounted in remote2vpn, account here - pd_account_stats(pd, data->vpn.fw_pctx); - data->vpn.fw_pctx = NULL; + } else { + // zdtun_forward was successful + if(data->vpn.fw_pctx) { + // not accounted in remote2vpn, account here + pd_account_stats(pd, data->vpn.fw_pctx); + data->vpn.fw_pctx = NULL; + } + + // First forwarded packet + if(data->sent_pkts == 1) { + // The socket is open only after zdtun_forward is called + socket_t sock = zdtun_conn_get_socket(conn); + + // In SOCKS5 with the PlaintextReceiver, we need the local port to the SOCKS5 proxy + if((sock != INVALID_SOCKET) && (tuple->ipver == 4)) { + // NOTE: the zdtun SOCKS5 implementation only supports IPv4 right now. + // If it also supported IPv6, than we would need to expose "sock_ipver" + struct sockaddr_in local_addr; + socklen_t addrlen = sizeof(local_addr); + + if(getsockname(sock, (struct sockaddr*) &local_addr, &addrlen) == 0) + data->vpn.local_port = local_addr.sin_port; + } + } } } else { pd_refresh_time(pd); diff --git a/app/src/main/jni/core/jni_impl.c b/app/src/main/jni/core/jni_impl.c index 65e67707..0228f95a 100644 --- a/app/src/main/jni/core/jni_impl.c +++ b/app/src/main/jni/core/jni_impl.c @@ -210,10 +210,12 @@ static int dumpNewConnection(pcapdroid_t *pd, const conn_and_tuple_t *conn, jobj jobject src_string = (*env)->NewStringUTF(env, srcip); jobject dst_string = (*env)->NewStringUTF(env, dstip); u_int ifidx = (pd->root_capture ? data->root.ifidx : 0); + u_int local_port = (!pd->root_capture ? data->vpn.local_port : 0); jobject conn_descriptor = (*env)->NewObject(env, cls.conn, mids.connInit, data->incr_id, conn_info->ipver, conn_info->ipproto, src_string, dst_string, ntohs(conn_info->src_port), ntohs(conn_info->dst_port), + ntohs(local_port), data->uid, ifidx, data->first_seen); if((conn_descriptor != NULL) && !jniCheckException(env)) { @@ -452,7 +454,7 @@ Java_com_emanuelef_remote_1capture_CaptureService_runPacketLoop(JNIEnv *env, jcl mids.getLibprogPath = jniGetMethodID(env, vpn_class, "getLibprogPath", "(Ljava/lang/String;)Ljava/lang/String;"); mids.notifyBlacklistsLoaded = jniGetMethodID(env, vpn_class, "notifyBlacklistsLoaded", "([Lcom/emanuelef/remote_capture/model/Blacklists$NativeBlacklistStatus;)V"); mids.getBlacklistsInfo = jniGetMethodID(env, vpn_class, "getBlacklistsInfo", "()[Lcom/emanuelef/remote_capture/model/BlacklistDescriptor;"); - mids.connInit = jniGetMethodID(env, cls.conn, "", "(IIILjava/lang/String;Ljava/lang/String;IIIIJ)V"); + mids.connInit = jniGetMethodID(env, cls.conn, "", "(IIILjava/lang/String;Ljava/lang/String;IIIIIJ)V"); mids.connProcessUpdate = jniGetMethodID(env, cls.conn, "processUpdate", "(Lcom/emanuelef/remote_capture/model/ConnectionUpdate;)V"); mids.connUpdateInit = jniGetMethodID(env, cls.conn_update, "", "(I)V"); mids.connUpdateSetStats = jniGetMethodID(env, cls.conn_update, "setStats", "(JJJIIIII)V"); diff --git a/app/src/main/jni/core/pcapdroid.c b/app/src/main/jni/core/pcapdroid.c index 593b010d..6bf9073d 100644 --- a/app/src/main/jni/core/pcapdroid.c +++ b/app/src/main/jni/core/pcapdroid.c @@ -468,6 +468,7 @@ void pd_giveup_dpi(pcapdroid_t *pd, pd_conn_t *data, const zdtun_5tuple_t *tuple /* ******************************************************* */ +// sync with PlaintextReceiver static int is_plaintext(char c) { return isprint(c) || (c == '\r') || (c == '\n') || (c == '\t'); } diff --git a/app/src/main/jni/core/pcapdroid.h b/app/src/main/jni/core/pcapdroid.h index 198b3d80..64c69cf4 100644 --- a/app/src/main/jni/core/pcapdroid.h +++ b/app/src/main/jni/core/pcapdroid.h @@ -39,7 +39,7 @@ #define MAX_HOST_LRU_SIZE 256 #define JAVA_PCAP_BUFFER_SIZE (512*1024) // 512K #define PERIODIC_PURGE_TIMEOUT_MS 5000 -#define MAX_PLAINTEXT_LENGTH 1024 +#define MAX_PLAINTEXT_LENGTH 1024 // sync with PlaintextReceiver #define MIN_REQ_PLAINTEXT_CHARS 3 // Minimum length (e.g. of "GET") to avoid reporting non-requests #define DNS_FLAGS_MASK 0x8000 @@ -74,6 +74,7 @@ typedef struct { } root; struct { struct pkt_context *fw_pctx; // context for the forwarded packet + uint16_t local_port; // local port, from zdtun to the Internet } vpn; }; diff --git a/tools/pcapdroid_mitm.py b/tools/pcapdroid_mitm.py new file mode 100755 index 00000000..45d51c3d --- /dev/null +++ b/tools/pcapdroid_mitm.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# +# This file is part of PCAPdroid. +# +# PCAPdroid is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PCAPdroid is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with PCAPdroid. If not, see . +# +# Copyright 2022 - Emanuele Faranda +# + +# mitmdump -q -p 8050 --mode socks5 -s pcapdroid.py +from enum import Enum +import socket +import errno +from mitmproxy import http +from mitmproxy.net.http.http1.assemble import assemble_request, assemble_response + +class PayloadType(Enum): + HTTP_REQUEST = "http_req" + HTTP_REPLY = "http_rep" + WEBSOCKET_CLIENT_MSG = "ws_climsg" + WEBSOCKET_SERVER_MSG = "ws_srvmsg" + +class PCAPdroid: + def __init__(self): + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect(("127.0.0.1", 5750)) + except socket.error as e: + print(e) + exit(1) # NOTE: this does not terminate mitmdump + + def send_payload(self, flow: http.HTTPFlow, payload_type: PayloadType, payload): + client_port = flow.client_conn.peername[1] + header = "%u:%s:%u\n" % (client_port, payload_type.value, len(payload)) + + try: + self.sock.sendall(header.encode('ascii')) + self.sock.sendall(payload) + except socket.error as e: + if e.errno == errno.EPIPE: + print("PCAPdroid closed") + exit(0) + else: + print(e) + exit(1) + + def request(self, flow: http.HTTPFlow): + if flow.request: + self.send_payload(flow, PayloadType.HTTP_REQUEST, assemble_request(flow.request)) + + def response(self, flow: http.HTTPFlow) -> None: + if flow.response: + self.send_payload(flow, PayloadType.HTTP_REPLY, assemble_response(flow.response)) + + def websocket_message(self, flow: http.HTTPFlow): + msg = flow.websocket.messages[-1] + if not msg: + return + + payload_type = PayloadType.WEBSOCKET_CLIENT_MSG if msg.from_client else PayloadType.WEBSOCKET_SERVER_MSG + self.send_payload(flow, payload_type, msg.content) + +addons = [ + PCAPdroid() +]