From 8ac6428bf1585b9a861fb05bf1348276b2b07727 Mon Sep 17 00:00:00 2001 From: emanuele-f Date: Tue, 30 Jan 2024 18:52:37 +0100 Subject: [PATCH] Allow capturing multiple target apps The target app option has been extended to allow the selection of multiple apps. Closes #146 --- app/src/main/AndroidManifest.xml | 4 + .../remote_capture/CaptureService.java | 39 +++-- .../activities/AppFilterActivity.java | 142 +++++++++++++++++ .../prefs/VpnExemptionsActivity.java | 10 +- .../fragments/StatusFragment.java | 144 +++++++++--------- .../remote_capture/model/CaptureSettings.java | 28 +++- .../emanuelef/remote_capture/model/Prefs.java | 34 ++++- app/src/main/jni/core/capture_libpcap.c | 73 +++++++-- app/src/main/jni/core/jni_impl.c | 38 ++++- app/src/main/jni/core/pcapdroid.c | 2 +- app/src/main/jni/core/pcapdroid.h | 7 +- app/src/main/jni/pcapd/pcapd.c | 2 +- app/src/main/jni/tests/test_utils.c | 1 - app/src/main/res/menu/hint_menu.xml | 12 ++ app/src/main/res/values/strings.xml | 2 + docs/app_api.md | 4 +- 16 files changed, 430 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/emanuelef/remote_capture/activities/AppFilterActivity.java create mode 100644 app/src/main/res/menu/hint_menu.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f9157e14..fc05e68b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -110,6 +110,10 @@ + uids = new ArrayList<>(); + + for (String package_name: mSettings.app_filter) { + int uid; + + try { + uid = Utils.getPackageUid(getPackageManager(), package_name, 0); + } catch (PackageManager.NameNotFoundException e) { + e.printStackTrace(); + continue; + } + + uids.add(uid); } + + // populate the array only with resolved UIDs + mAppFilterUids = new int[uids.size()]; + + int i = 0; + for (Integer uid: uids) + mAppFilterUids[i++] = uid; } else - app_filter_uid = -1; + mAppFilterUids = new int[0]; mMalwareDetectionEnabled = Prefs.isMalwareDetectionEnabled(this, mPrefs); mFirewallEnabled = Prefs.isFirewallEnabled(this, mPrefs); @@ -458,7 +474,8 @@ public class CaptureService extends VpnService implements Runnable { // NOTE: the API requires a package name, however it is converted to a UID // (see Vpn.java addUserToRanges). This means that vpn routing happens on a UID basis, // not on a package-name basis! - builder.addAllowedApplication(mSettings.app_filter); + for (String package_name: mSettings.app_filter) + builder.addAllowedApplication(package_name); } catch (PackageManager.NameNotFoundException e) { String msg = String.format(getResources().getString(R.string.app_not_found), mSettings.app_filter); Toast.makeText(this, msg, Toast.LENGTH_SHORT).show(); @@ -958,7 +975,7 @@ public class CaptureService extends VpnService implements Runnable { return rv; } - public static String getAppFilter() { + public static Set getAppFilter() { return((INSTANCE != null) ? INSTANCE.mSettings.app_filter : null); } @@ -1291,7 +1308,7 @@ public class CaptureService extends VpnService implements Runnable { public int isPcapngEnabled() { return(mSettings.pcapng_format ? 1 : 0); } - public int getAppFilterUid() { return(app_filter_uid); } + public int[] getAppFilterUids() { return(mAppFilterUids); } public int getMitmAddonUid() { return MitmAddon.getUid(this); diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/AppFilterActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/AppFilterActivity.java new file mode 100644 index 00000000..d9c646db --- /dev/null +++ b/app/src/main/java/com/emanuelef/remote_capture/activities/AppFilterActivity.java @@ -0,0 +1,142 @@ +package com.emanuelef.remote_capture.activities; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Build; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.view.MenuProvider; +import androidx.preference.PreferenceManager; + +import com.emanuelef.remote_capture.Log; +import com.emanuelef.remote_capture.R; +import com.emanuelef.remote_capture.Utils; +import com.emanuelef.remote_capture.fragments.AppsToggles; +import com.emanuelef.remote_capture.model.AppDescriptor; +import com.emanuelef.remote_capture.model.Prefs; + +import java.util.HashSet; +import java.util.Set; + +public class AppFilterActivity extends BaseActivity implements MenuProvider { + private static final String TAG = "AppFilterActivity"; + private AppFilterFragment mFragment; + + @Override + @SuppressWarnings("deprecation") + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.target_apps); + setContentView(R.layout.fragment_activity); + addMenuProvider(this); + displayBackAction(); + + if (savedInstanceState != null) + mFragment = (AppFilterFragment) getSupportFragmentManager().getFragment(savedInstanceState, "fragment"); + if (mFragment == null) + mFragment = new AppFilterFragment(); + + getSupportFragmentManager().beginTransaction() + .replace(R.id.fragment, mFragment) + .commit(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + overrideActivityTransition(OVERRIDE_TRANSITION_OPEN, 0, 0); + else + overridePendingTransition(0, 0); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + getSupportFragmentManager().putFragment(outState, "fragment", mFragment); + } + + @Override + public void onCreateMenu(@NonNull Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.hint_menu, menu); + } + + @Override + public boolean onMenuItemSelected(@NonNull MenuItem item) { + int id = item.getItemId(); + + if (id == R.id.show_hint) { + Utils.showHelpDialog(this, R.string.target_apps_help); + return true; + } + + return false; + } + + @Override + @SuppressWarnings("deprecation") + public void onBackPressed() { + if(mFragment.onBackPressed()) + return; + + super.onBackPressed(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + overrideActivityTransition(OVERRIDE_TRANSITION_CLOSE, 0, 0); + else + overridePendingTransition(0, 0); + } + + public static class AppFilterFragment extends AppsToggles { + private final Set mSelectedApps = new HashSet<>(); + private @Nullable SharedPreferences mPrefs; + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + mPrefs = PreferenceManager.getDefaultSharedPreferences(context); + assert mPrefs != null; + + mSelectedApps.clear(); + + Set saved = Prefs.getStringSet(mPrefs, Prefs.PREF_APP_FILTER); + if(!saved.isEmpty()) { + Log.d(TAG, "Loading " + saved.size() + " target apps"); + mSelectedApps.addAll(saved); + } + } + + @Override + public void onDetach() { + super.onDetach(); + mPrefs = null; + } + + @Override + protected Set getCheckedApps() { + return mSelectedApps; + } + + @Override + public void onAppToggled(AppDescriptor app, boolean checked) { + String packageName = app.getPackageName(); + if(mSelectedApps.contains(packageName) == checked) + return; // nothing to do + + if(checked) + mSelectedApps.add(packageName); + else + mSelectedApps.remove(packageName); + + Log.d(TAG, "Saving " + mSelectedApps.size() + " target apps"); + + if(mPrefs == null) + return; + + mPrefs.edit() + .putStringSet(Prefs.PREF_APP_FILTER, mSelectedApps) + .apply(); + } + } +} diff --git a/app/src/main/java/com/emanuelef/remote_capture/activities/prefs/VpnExemptionsActivity.java b/app/src/main/java/com/emanuelef/remote_capture/activities/prefs/VpnExemptionsActivity.java index de1fc69c..2a7c852f 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/activities/prefs/VpnExemptionsActivity.java +++ b/app/src/main/java/com/emanuelef/remote_capture/activities/prefs/VpnExemptionsActivity.java @@ -46,6 +46,7 @@ public class VpnExemptionsActivity extends BaseActivity { super.onCreate(savedInstanceState); setTitle(R.string.vpn_exemptions); setContentView(R.layout.fragment_activity); + displayBackAction(); if(savedInstanceState != null) mFragment = (VpnExceptionsFragment) getSupportFragmentManager().getFragment(savedInstanceState, "fragment"); @@ -63,15 +64,6 @@ public class VpnExemptionsActivity extends BaseActivity { getSupportFragmentManager().putFragment(outState, "fragment", mFragment); } - @Override - public boolean onOptionsItemSelected(@NonNull MenuItem item) { - if(item.getItemId() == android.R.id.home) { - onBackPressed(); - return true; - } - return super.onOptionsItemSelected(item); - } - @Override @SuppressWarnings("deprecation") public void onBackPressed() { diff --git a/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java b/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java index 613b6198..2956a1b6 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java +++ b/app/src/main/java/com/emanuelef/remote_capture/fragments/StatusFragment.java @@ -14,19 +14,21 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2020-21 - Emanuele Faranda + * Copyright 2020-24 - Emanuele Faranda */ package com.emanuelef.remote_capture.fragments; import android.annotation.SuppressLint; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.method.LinkMovementMethod; +import android.util.Pair; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -38,8 +40,8 @@ import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.SwitchCompat; +import androidx.core.content.ContextCompat; import androidx.core.view.MenuProvider; import androidx.fragment.app.Fragment; import androidx.lifecycle.Lifecycle; @@ -48,6 +50,7 @@ import androidx.preference.PreferenceManager; import com.emanuelef.remote_capture.AppsResolver; import com.emanuelef.remote_capture.Log; import com.emanuelef.remote_capture.MitmReceiver; +import com.emanuelef.remote_capture.activities.AppFilterActivity; import com.emanuelef.remote_capture.model.AppDescriptor; import com.emanuelef.remote_capture.model.AppState; import com.emanuelef.remote_capture.CaptureService; @@ -57,9 +60,11 @@ import com.emanuelef.remote_capture.activities.MainActivity; import com.emanuelef.remote_capture.interfaces.AppStateListener; import com.emanuelef.remote_capture.model.Prefs; import com.emanuelef.remote_capture.model.CaptureStats; -import com.emanuelef.remote_capture.views.AppSelectDialog; import com.emanuelef.remote_capture.views.PrefSpinner; +import java.util.ArrayList; +import java.util.Set; + public class StatusFragment extends Fragment implements AppStateListener, MenuProvider { private static final String TAG = "StatusFragment"; private Handler mHandler; @@ -75,9 +80,8 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr private SharedPreferences mPrefs; private TextView mFilterDescription; private SwitchCompat mAppFilterSwitch; - private String mAppFilter; + private Set mAppFilter; private TextView mFilterRootDecryptionWarning; - private AppSelectDialog mAppSelDialog; @Override public void onAttach(@NonNull Context context) { @@ -88,7 +92,6 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr @Override public void onDetach() { super.onDetach(); - abortAppSelection(); mActivity.setAppStateListener(null); mActivity = null; } @@ -145,14 +148,11 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr }); } - filterTitle.setText(R.string.app_filter); + filterTitle.setText(R.string.target_apps); - mAppFilterSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - if(isChecked) { - if((mAppFilter == null) || (mAppFilter.isEmpty())) - openAppFilterSelector(); - } else - setAppFilter(null); + mAppFilterSwitch.setOnClickListener((buttonView) -> { + mAppFilterSwitch.setChecked(!mAppFilterSwitch.isChecked()); + openAppFilterSelector(); }); refreshFilterInfo(); @@ -222,34 +222,20 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr mAppFilterSwitch.setChecked(true); - AppDescriptor app = AppsResolver.resolveInstalledApp(context.getPackageManager(), mAppFilter, 0); - String description; + Pair pair = getAppFilterTextAndIcon(context); - if(app == null) - description = mAppFilter; - else { - description = app.getName() + " (" + app.getPackageName() + ")"; + mFilterDescription.setText(pair.first); + + if (pair.second != null) { int height = mFilterDescription.getMeasuredHeight(); - if((height > 0) && (app.getIcon() != null)) { - Drawable drawable = Utils.scaleDrawable(context.getResources(), app.getIcon(), height, height); + if(height > 0) { + Drawable drawable = Utils.scaleDrawable(context.getResources(), pair.second, height, height); if(drawable != null) mFilterDescription.setCompoundDrawablesWithIntrinsicBounds(drawable, null, null, null); } } - - mFilterDescription.setText(description); - } - - private void setAppFilter(AppDescriptor filter) { - SharedPreferences.Editor editor = mPrefs.edit(); - mAppFilter = (filter != null) ? filter.getPackageName() : ""; - - editor.putString(Prefs.PREF_APP_FILTER, mAppFilter); - editor.apply(); - refreshFilterInfo(); - recheckFilterWarning(); } private void onStatsUpdate(CaptureStats stats) { @@ -259,6 +245,42 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr mCaptureStatus.setText(Utils.formatBytes(stats.bytes_sent + stats.bytes_rcvd)); } + private Pair getAppFilterTextAndIcon(@NonNull Context context) { + Drawable icon = null; + String text = ""; + + if((mAppFilter != null) && (!mAppFilter.isEmpty())) { + if (mAppFilter.size() == 1) { + // only a single app is selected, show its image and text + String package_name = mAppFilter.iterator().next(); + AppDescriptor app = AppsResolver.resolveInstalledApp(requireContext().getPackageManager(), package_name, 0); + + if((app != null) && (app.getIcon() != null)) { + icon = app.getIcon(); + text = app.getName() + " (" + app.getPackageName() + ")"; + } + } else { + // multiple apps, show default icon and comprehensive text + icon = ContextCompat.getDrawable(context, R.drawable.ic_image); + ArrayList parts = new ArrayList<>(); + + for (String package_name: mAppFilter) { + AppDescriptor app = AppsResolver.resolveInstalledApp(requireContext().getPackageManager(), package_name, 0); + String tmp = package_name; + + if (app != null) + tmp = app.getName(); + + parts.add(tmp); + } + + text = Utils.shorten(String.join(", ", parts), 48); + } + } + + return new Pair<>(text, icon); + } + private void refreshPcapDumpInfo() { String info = ""; @@ -287,26 +309,29 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr mCollectorInfo.setText(info); - // Check if a filter is set - if((mAppFilter != null) && (!mAppFilter.isEmpty())) { - AppDescriptor app = AppsResolver.resolveInstalledApp(requireContext().getPackageManager(), mAppFilter, 0); + // Rendering after mCollectorInfo.setText is deferred, so getMeasuredHeight must be postponed + mHandler.post(() -> { + Context context = getContext(); + if(context == null) + return; - if((app != null) && (app.getIcon() != null)) { - // Rendering after mCollectorInfo.setText is deferred, so getMeasuredHeight must be postponed - mHandler.post(() -> { - if(getContext() == null) - return; + // Check if a filter is set + if((mAppFilter != null) && (!mAppFilter.isEmpty())) { + Pair pair = getAppFilterTextAndIcon(context); + if (pair.second != null) { + // scale and set int height = mCollectorInfo.getMeasuredHeight(); - Drawable drawable = Utils.scaleDrawable(getResources(), app.getIcon(), height, height); + Drawable drawable = Utils.scaleDrawable(getResources(), pair.second, height, height); - if(drawable != null) + if (drawable != null) mCollectorInfo.setCompoundDrawablesWithIntrinsicBounds(null, null, drawable, null); - }); - } else - mCollectorInfo.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); - } else - mCollectorInfo.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); + } + } + }); + + // will be overriden in the above handler if necessary + mCollectorInfo.setCompoundDrawablesWithIntrinsicBounds(null, null, null, null); } @Override @@ -386,26 +411,7 @@ public class StatusFragment extends Fragment implements AppStateListener, MenuPr } private void openAppFilterSelector() { - mAppSelDialog = new AppSelectDialog((AppCompatActivity) requireActivity(), R.string.app_filter, - new AppSelectDialog.AppSelectListener() { - @Override - public void onSelectedApp(AppDescriptor app) { - abortAppSelection(); - setAppFilter(app); - } - - @Override - public void onAppSelectionAborted() { - abortAppSelection(); - setAppFilter(null); - } - }); - } - - private void abortAppSelection() { - if(mAppSelDialog != null) { - mAppSelDialog.abort(); - mAppSelDialog = null; - } + Intent intent = new Intent(requireContext(), AppFilterActivity.class); + startActivity(intent); } } diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java b/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java index d46c01d4..198ed456 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java +++ b/app/src/main/java/com/emanuelef/remote_capture/model/CaptureSettings.java @@ -8,10 +8,15 @@ import android.os.Bundle; import com.emanuelef.remote_capture.Billing; import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; public class CaptureSettings implements Serializable { public Prefs.DumpMode dump_mode; - public String app_filter; + public Set app_filter; public String collector_address; public int collector_port; public int http_server_port; @@ -62,7 +67,7 @@ public class CaptureSettings implements Serializable { public CaptureSettings(Context ctx, Intent intent) { dump_mode = Prefs.getDumpMode(getString(intent, "pcap_dump_mode", "none")); - app_filter = getString(intent, Prefs.PREF_APP_FILTER, ""); + app_filter = new HashSet<>(getStringList(intent, Prefs.PREF_APP_FILTER)); collector_address = getString(intent, Prefs.PREF_COLLECTOR_IP_KEY, "127.0.0.1"); collector_port = getInt(intent, Prefs.PREF_COLLECTOR_PORT_KEY, 1234); http_server_port = getInt(intent, Prefs.PREF_HTTP_SERVER_PORT, 8080); @@ -113,6 +118,25 @@ public class CaptureSettings implements Serializable { return bundle.getBoolean(key, def_value); } + // get a list of comma-separated strings from the bundle + private static List getStringList(Intent intent, String key) { + List rv; + + String s = intent.getStringExtra(key); + if(s != null) { + if (s.indexOf(',') < 0) { + rv = new ArrayList<>(); + rv.add(s); + } else { + String[] arr = s.split(","); + rv = Arrays.asList(arr); + } + } else + rv = new ArrayList<>(); + + return rv; + } + public boolean readFromPcap() { return input_pcap_path != null; } diff --git a/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java b/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java index b9b7712c..fc998ced 100644 --- a/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java +++ b/app/src/main/java/com/emanuelef/remote_capture/model/Prefs.java @@ -19,9 +19,11 @@ package com.emanuelef.remote_capture.model; +import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; +import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import com.emanuelef.remote_capture.Billing; @@ -29,6 +31,9 @@ import com.emanuelef.remote_capture.BuildConfig; import com.emanuelef.remote_capture.MitmAddon; import com.emanuelef.remote_capture.Utils; +import java.util.HashSet; +import java.util.Set; + public class Prefs { public static final String DUMP_NONE = "none"; public static final String DUMP_HTTP_SERVER = "http_server"; @@ -199,7 +204,7 @@ public class Prefs { public static boolean isSocks5AuthEnabled(SharedPreferences p) { return(p.getBoolean(PREF_SOCKS5_AUTH_ENABLED_KEY, false)); } public static String getSocks5Username(SharedPreferences p) { return(p.getString(PREF_SOCKS5_USERNAME_KEY, "")); } public static String getSocks5Password(SharedPreferences p) { return(p.getString(PREF_SOCKS5_PASSWORD_KEY, "")); } - public static String getAppFilter(SharedPreferences p) { return(p.getString(PREF_APP_FILTER, "")); } + public static Set getAppFilter(SharedPreferences p) { return(getStringSet(p, PREF_APP_FILTER)); } public static IpMode getIPMode(SharedPreferences p) { return(getIPMode(p.getString(PREF_IP_MODE, IP_MODE_DEFAULT))); } public static BlockQuicMode getBlockQuicMode(SharedPreferences p) { return(getBlockQuicMode(p.getString(PREF_BLOCK_QUIC, BLOCK_QUIC_MODE_DEFAULT))); } public static boolean useEnglishLanguage(SharedPreferences p){ return("english".equals(p.getString(PREF_APP_LANGUAGE, "system")));} @@ -234,6 +239,31 @@ public class Prefs { public static String getDnsServerV4(SharedPreferences p) { return(p.getString(PREF_DNS_SERVER_V4, "1.1.1.1")); } public static String getDnsServerV6(SharedPreferences p) { return(p.getString(PREF_DNS_SERVER_V6, "2606:4700:4700::1111")); } + // Gets a StringSet from the prefs + // The preference should either be a StringSet or a String + // An empty set is returned as the default value + @SuppressLint("MutatingSharedPrefs") + public static @NonNull Set getStringSet(SharedPreferences p, String key) { + Set rv = null; + + try { + rv = p.getStringSet(key, null); + } catch (ClassCastException e) { + // retry with string + String s = p.getString(key, ""); + + if (!s.isEmpty()) { + rv = new HashSet<>(); + rv.add(s); + } + } + + if (rv == null) + rv = new HashSet<>(); + + return rv; + } + public static String asString(Context ctx) { SharedPreferences p = PreferenceManager.getDefaultSharedPreferences(ctx); @@ -252,7 +282,7 @@ public class Prefs { "\nFirewall: " + isFirewallEnabled(ctx, p) + "\nPCAPNG: " + isPcapngEnabled(ctx, p) + "\nBlockNewApps: " + blockNewApps(p) + - "\nAppFilter: " + getAppFilter(p) + + "\nTargetApps: " + getAppFilter(p) + "\nIpMode: " + getIPMode(p) + "\nTrailer: " + isPcapdroidTrailerEnabled(p) + "\nStartAtBoot: " + startAtBoot(p); diff --git a/app/src/main/jni/core/capture_libpcap.c b/app/src/main/jni/core/capture_libpcap.c index 178c682e..2aa89e79 100644 --- a/app/src/main/jni/core/capture_libpcap.c +++ b/app/src/main/jni/core/capture_libpcap.c @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2021 - Emanuele Faranda + * Copyright 2021-24 - Emanuele Faranda */ #include @@ -103,6 +103,25 @@ static bool valid_bpf(const char *bpf) { /* ******************************************************* */ +static const char* get_uids_filter(pcapdroid_t *pd, char *buf, size_t buf_size) { + if (pd->tls_decryption.enabled || (pd->pcap.app_filter_uids_size <= 0)) + return "-1"; + + size_t off = 0; + + for (int i = 0; (i < pd->pcap.app_filter_uids_size) && (off < buf_size); i++) { + const char * fmt = (i == 0) ? "%d" : ",%d"; + off += snprintf(buf + off, (buf_size - off), fmt, pd->pcap.app_filter_uids[i]); + } + + if (off >= buf_size) + log_e("The UID filter has been truncated"); + + return buf; +} + +/* ******************************************************* */ + static int connectPcapd(pcapdroid_t *pd) { int sock; int client = -1; @@ -185,9 +204,11 @@ static int connectPcapd(pcapdroid_t *pd) { } // Start the daemon + char uids_filter_buf[128]; char args[256]; - snprintf(args, sizeof(args), "-l pcapd.log -L %u -i '%s' -u %d -t -b '%s'%s", - getuid(), pd->pcap.capture_interface, pd->tls_decryption.enabled ? -1 : pd->app_filter, + snprintf(args, sizeof(args), "-l pcapd.log -L %u -i '%s' -u %s -t -b '%s'%s", + getuid(), pd->pcap.capture_interface, + get_uids_filter(pd, uids_filter_buf, sizeof(uids_filter_buf)), bpf, pd->pcap.daemonize ? " -d" : ""); pid = start_subprocess(pcapd, args, pd->pcap.as_root, NULL); @@ -271,10 +292,10 @@ cleanup: /* ******************************************************* */ -static char* get_mitm_redirection_args(pcapdroid_t *pd, char *buf, bool add) { +static char* get_mitm_redirection_args(pcapdroid_t *pd, char *buf, int uid, bool add) { int off = sprintf(buf, "-t nat -%c OUTPUT -p tcp -m owner ", add ? 'I' : 'D'); - if(pd->app_filter >= 0) - off += sprintf(buf + off, "--uid-owner %d", pd->app_filter); + if(uid >= 0) + off += sprintf(buf + off, "--uid-owner %d", uid); else off += sprintf(buf + off, "! --uid-owner %d", pd->mitm_addon_uid); sprintf(buf + off, " -j REDIRECT --to 7780"); @@ -604,6 +625,7 @@ int run_libpcap(pcapdroid_t *pd) { char bpf[256]; bpf[0] = '\0'; + pd->pcap.app_filter_uids_size = getIntArrayPref(pd->env, pd->capture_service, "getAppFilterUids", &pd->pcap.app_filter_uids); pd->pcap.as_root = !pd->pcap_file_capture; pd->pcap.bpf = getStringPref(pd, "getPcapDumperBpf", bpf, sizeof(bpf)); pd->pcap.capture_interface = getStringPref(pd, "getCaptureInterface", capture_interface, sizeof(capture_interface)); @@ -628,10 +650,23 @@ int run_libpcap(pcapdroid_t *pd) { if(pd->tls_decryption.enabled) { char args[128]; - if(run_shell_cmd("iptables", get_mitm_redirection_args(pd, args, true), true, true) != 0) - goto cleanup; + if(pd->pcap.app_filter_uids_size > 0) { + for (int i = 0; i < pd->pcap.app_filter_uids_size; i++) { + int uid = pd->pcap.app_filter_uids[i]; - iptables_cleanup = true; + if (uid >= 0) { + if(run_shell_cmd("iptables", get_mitm_redirection_args(pd, args, uid, true), true, true) != 0) + goto cleanup; + + iptables_cleanup = true; + } + } + } else { + if(run_shell_cmd("iptables", get_mitm_redirection_args(pd, args, -1, true), true, true) != 0) + goto cleanup; + + iptables_cleanup = true; + } } pd_refresh_time(pd); @@ -722,7 +757,16 @@ cleanup: if(iptables_cleanup) { char args[128]; - run_shell_cmd("iptables", get_mitm_redirection_args(pd, args, false), true, false); + + if(pd->pcap.app_filter_uids_size > 0) { + for (int i = 0; i < pd->pcap.app_filter_uids_size; i++) { + int uid = pd->pcap.app_filter_uids[i]; + + if (uid >= 0) + run_shell_cmd("iptables", get_mitm_redirection_args(pd, args, uid, false), true, false); + } + } else + run_shell_cmd("iptables", get_mitm_redirection_args(pd, args, -1, false), true, false); } if((pd->pcap.pcapd_pid > 0) && !pd->pcap.daemonize) { @@ -735,5 +779,14 @@ cleanup: process_pcapd_rv(pd, WEXITSTATUS(status)); } +#if ANDROID + if (pd->pcap.app_filter_uids) { + pd_free(pd->pcap.app_filter_uids); + + pd->pcap.app_filter_uids = NULL; + pd->pcap.app_filter_uids_size = 0; + } +#endif + return rv; } diff --git a/app/src/main/jni/core/jni_impl.c b/app/src/main/jni/core/jni_impl.c index e7312b4d..76b5a37e 100644 --- a/app/src/main/jni/core/jni_impl.c +++ b/app/src/main/jni/core/jni_impl.c @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2022 - Emanuele Faranda + * Copyright 2022-24 - Emanuele Faranda */ #if ANDROID @@ -585,7 +585,6 @@ Java_com_emanuelef_remote_1capture_CaptureService_runPacketLoop(JNIEnv *env, jcl .notify_blacklists_loaded = notifyBlacklistsLoaded, .dump_payload_chunk = dumpPayloadChunk, }, - .app_filter = getIntPref(env, vpn, "getAppFilterUid"), .mitm_addon_uid = getIntPref(env, vpn, "getMitmAddonUid"), .vpn_capture = (bool) getIntPref(env, vpn, "isVpnCapture"), .pcap_file_capture = (bool) getIntPref(env, vpn, "isPcapFileCapture"), @@ -1213,6 +1212,41 @@ int getIntPref(JNIEnv *env, jobject vpn_inst, const char *key) { /* ******************************************************* */ +// Retrieve a int[] pref. +// If rv is >0, out points to the allocated array. It's up to the caller to free it with pd_free +int getIntArrayPref(JNIEnv *env, jobject vpn_inst, const char *key, int **out) { + int rv = -1; + jmethodID midMethod = jniGetMethodID(env, cls.vpn_service, key, "()[I"); + jintArray jarr = (jintArray) (*env)->CallObjectMethod(env, vpn_inst, midMethod); + + if (!jniCheckException(env)) { + int size = (*env)->GetArrayLength(env, jarr); + log_d("getIntArrayPref(%s) = #%d", key, size); + + if (size > 0) { + jint *array = (*env)->GetIntArrayElements(env, jarr, NULL); + if (array) { + size_t arr_size = size * sizeof(int); + + *out = (int*) pd_malloc(arr_size); + if (*out) { + // success + memcpy(*out, array, arr_size); + rv = size; + } + + (*env)->ReleaseIntArrayElements(env, jarr, array, 0); + } + } else + rv = size; + } + + (*env)->DeleteLocalRef(env, jarr); + return rv; +} + +/* ******************************************************* */ + void getApplicationByUid(pcapdroid_t *pd, jint uid, char *buf, int bufsize) { JNIEnv *env = pd->env; const char *value = NULL; diff --git a/app/src/main/jni/core/pcapdroid.c b/app/src/main/jni/core/pcapdroid.c index 29db9f6f..11495298 100644 --- a/app/src/main/jni/core/pcapdroid.c +++ b/app/src/main/jni/core/pcapdroid.c @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2020-21 - Emanuele Faranda + * Copyright 2020-24 - Emanuele Faranda */ #include diff --git a/app/src/main/jni/core/pcapdroid.h b/app/src/main/jni/core/pcapdroid.h index c90badb0..72462915 100644 --- a/app/src/main/jni/core/pcapdroid.h +++ b/app/src/main/jni/core/pcapdroid.h @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2020-21 - Emanuele Faranda + * Copyright 2020-24 - Emanuele Faranda */ #ifndef __PCAPDROID_H__ @@ -195,7 +195,6 @@ typedef struct pcapdroid { int filesdir_len; // config - jint app_filter; jint mitm_addon_uid; bool vpn_capture; bool pcap_file_capture; @@ -233,6 +232,9 @@ typedef struct pcapdroid { char *bpf; char *capture_interface; int pcapd_pid; + + int *app_filter_uids; + int app_filter_uids_size; } pcap; }; @@ -406,6 +408,7 @@ uint16_t pd_ndpi2proto(ndpi_protocol proto); char* getStringPref(pcapdroid_t *pd, const char *key, char *buf, int bufsize); int getIntPref(JNIEnv *env, jobject vpn_inst, const char *key); +int getIntArrayPref(JNIEnv *env, jobject vpn_inst, const char *key, int **out); zdtun_ip_t getIPPref(JNIEnv *env, jobject vpn_inst, const char *key, int *ip_ver); uint32_t getIPv4Pref(JNIEnv *env, jobject vpn_inst, const char *key); struct in6_addr getIPv6Pref(JNIEnv *env, jobject vpn_inst, const char *key); diff --git a/app/src/main/jni/pcapd/pcapd.c b/app/src/main/jni/pcapd/pcapd.c index fc56699e..f3ab5f58 100644 --- a/app/src/main/jni/pcapd/pcapd.c +++ b/app/src/main/jni/pcapd/pcapd.c @@ -14,7 +14,7 @@ * You should have received a copy of the GNU General Public License * along with PCAPdroid. If not, see . * - * Copyright 2021-23 - Emanuele Faranda + * Copyright 2021-24 - Emanuele Faranda */ /* diff --git a/app/src/main/jni/tests/test_utils.c b/app/src/main/jni/tests/test_utils.c index 40de5c8d..c2bec727 100644 --- a/app/src/main/jni/tests/test_utils.c +++ b/app/src/main/jni/tests/test_utils.c @@ -89,7 +89,6 @@ pcapdroid_t* pd_init_test(const char *ifname) { pd->pcap_file_capture = true; pd->pcap.capture_interface = (char*) ifname; pd->pcap.as_root = false; // don't run as root - pd->app_filter = -1; // don't filter pd->cb.get_libprog_path = getPcapdPath; pd->payload_mode = PAYLOAD_MODE_FULL; diff --git a/app/src/main/res/menu/hint_menu.xml b/app/src/main/res/menu/hint_menu.xml new file mode 100644 index 00000000..9b0e9efa --- /dev/null +++ b/app/src/main/res/menu/hint_menu.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d292498c..ada18413 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -501,4 +501,6 @@ Always Only for connections to decrypt Decrypting QUIC is currently not supported. As a workaround, stop the capture and select the option to block QUIC in the PCAPdroid settings + Target apps + Select the applications to capture diff --git a/docs/app_api.md b/docs/app_api.md index d590b6ba..badc3df7 100644 --- a/docs/app_api.md +++ b/docs/app_api.md @@ -79,7 +79,7 @@ As shown above, the capture settings can be specified by using intent extras. Th | Parameter | Type | Ver | Mode | Value | |-------------------------|--------|-----|------|--------------------------------------------------------------------| | pcap_dump_mode | string | | | none \| http_server \| udp_exporter \| pcap_file | -| app_filter | string | | | the package name of the app to capture | +| app_filter | string | | | package name of the app(s) to capture (73+: comma separated list) | | collector_ip_address | string | | | the IP address of the collector in udp_exporter mode | | collector_port | int | | | the UDP port of the collector in udp_exporter mode | | http_server_port | int | | | the HTTP server port in http_server mode | @@ -101,7 +101,7 @@ As shown above, the capture settings can be specified by using intent extras. Th | pcapng_format | bool | 62 | | true to use the PCAPNG dump format (overrides pcapdroid_trailer)* | | socks5_username | string | 64 | vpn | username for the optional SOCKS5 proxy authentication | | socks5_password | string | 64 | vpn | password for the optional SOCKS5 proxy authentication | -| block_quic | string | 73 | vpn | never | always | to_decrypt (matching the decryption whitelist) | +| block_quic | string | 73 | vpn | never \| always \| to_decrypt (matching the decryption whitelist) | \*: paid feature