/* Hypatia: A realtime malware scanner for Android Copyright (c) 2017-2018 Divested Computing Group This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program 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 Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package us.spotco.malwarescanner; import android.app.Activity; import android.app.Notification; import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.AsyncTask; import android.os.Build; import android.os.Environment; import android.os.SystemClock; import android.widget.TextView; import com.google.common.hash.BloomFilter; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.math.BigInteger; import java.security.MessageDigest; import java.text.NumberFormat; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Random; import java.util.Set; import java.util.concurrent.ConcurrentSkipListSet; class MalwareScanner extends AsyncTask, Object, String> { private final Context context; private TextView logOutput = null; private final boolean userFacing; private final NotificationManager notificationManager; private long scanStartTime = 0; private final HashMap fileHashesMD5 = new HashMap<>(); private final HashMap fileHashesSHA1 = new HashMap<>(); private final HashMap fileHashesSHA256 = new HashMap<>(); public boolean running = false; private int amtMatchedFiles = 0; public MalwareScanner(Activity activity, Context context, boolean userFacing) { this.context = context; this.userFacing = userFacing; if (activity != null) { logOutput = activity.findViewById(R.id.txtLogOutput); } notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { NotificationChannel detectionChannel = new NotificationChannel("DETECTION", context.getString(R.string.lblNotificationMalwareDetectionTitle), NotificationManager.IMPORTANCE_HIGH); detectionChannel.setDescription(context.getString(R.string.lblNotificationMalwareDetectionDescription)); notificationManager.createNotificationChannel(detectionChannel); } } private void logResult(String result, String hashsum) { if (result.startsWith("Potential match")) { String[] malwareDetect = result.split(" in "); if (hashsum != null) { //Skip if we've alerted on this file already in the past 15 seconds if (Utils.MATCHED_FILES_TIME.containsKey(result + hashsum) && ((System.currentTimeMillis() - Utils.MATCHED_FILES_TIME.get(result + hashsum)) < (15 * 1000))) { return; } Utils.MATCHED_FILES_TIME.put(result + hashsum, System.currentTimeMillis()); } int notificationId = new Random().nextInt(); Notification.Builder mBuilder = new Notification.Builder(context.getApplicationContext()) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getText(R.string.lblNotificationRealtimeDetection) + " " + malwareDetect[0]) .setContentText("File: >>>" + malwareDetect[1] + "<<<") .setStyle(new Notification.BigTextStyle().bigText("File: >>>" + malwareDetect[1] + "<<<\nID: >>>" + hashsum.substring(0, 8) + "<<<")) .setOngoing(true) .setPriority(Notification.PRIORITY_MAX) .setDefaults(Notification.DEFAULT_VIBRATE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { mBuilder.setVisibility(Notification.VISIBILITY_SECRET); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { mBuilder.setChannelId("DETECTION"); } //Lookup action Intent lookupIntent = new Intent(context, NotificationPromptActivity.class); lookupIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_NEW_TASK); lookupIntent.setAction("us.spotco.malwarescanner.LOOKUP_HASH"); lookupIntent.putExtra("NOTIFICATION_ID", notificationId); lookupIntent.putExtra("HASH", hashsum); PendingIntent lookupIntentPending = PendingIntent.getActivity(context, notificationId + 1, lookupIntent, PendingIntent.FLAG_IMMUTABLE); mBuilder.addAction(android.R.drawable.ic_dialog_map, context.getText(R.string.lookupVT), lookupIntentPending); //Delete action if (malwareDetect[1].startsWith("~/")) { Intent deleteIntent = new Intent(context, NotificationPromptActivity.class); deleteIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_NEW_TASK); deleteIntent.setAction("us.spotco.malwarescanner.DELETE_FILE"); deleteIntent.putExtra("NOTIFICATION_ID", notificationId); deleteIntent.putExtra("FILE_PATH", malwareDetect[1].replaceFirst("~", Environment.getExternalStorageDirectory().toString())); PendingIntent deleteIntentPending = PendingIntent.getActivity(context, notificationId + 2, deleteIntent, PendingIntent.FLAG_IMMUTABLE); mBuilder.addAction(android.R.drawable.ic_delete, context.getText(R.string.deleteFile), deleteIntentPending); } else if (malwareDetect[1].startsWith("/data/app")) { Intent uninstallIntent = new Intent(context, NotificationPromptActivity.class); uninstallIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_NEW_TASK); uninstallIntent.setAction("us.spotco.malwarescanner.UNINSTALL_APP"); uninstallIntent.putExtra("NOTIFICATION_ID", notificationId); uninstallIntent.putExtra("FILE_PATH", malwareDetect[1]); PendingIntent uninstallIntentPending = PendingIntent.getActivity(context, notificationId + 3, uninstallIntent, PendingIntent.FLAG_IMMUTABLE); mBuilder.addAction(android.R.drawable.ic_delete, context.getText(R.string.uninstallApp), uninstallIntentPending); } //Ignore action Intent ignoreIntent = new Intent(context, NotificationPromptActivity.class); ignoreIntent.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT | Intent.FLAG_ACTIVITY_NEW_TASK); ignoreIntent.setAction("us.spotco.malwarescanner.IGNORE_DETECTION"); ignoreIntent.putExtra("NOTIFICATION_ID", notificationId); PendingIntent ignoreIntentPending = PendingIntent.getActivity(context, notificationId + 4, ignoreIntent, PendingIntent.FLAG_IMMUTABLE); mBuilder.addAction(android.R.drawable.ic_menu_view, context.getText(R.string.ignoreDetection), ignoreIntentPending); //Show it! notificationManager.notify(notificationId, mBuilder.build()); } else if (userFacing) { logOutput.append(result); if (!(result.length() <= 3)) { logOutput.append("\n"); } } } @Override protected final void onPreExecute() { scanStartTime = SystemClock.elapsedRealtime(); logResult(context.getString(R.string.main_starting_scan), null); } @Override protected final String doInBackground(HashSet[] filesToScan) { running = true; int startCount = amtMatchedFiles; ConcurrentSkipListSet filesToScanReal = new ConcurrentSkipListSet<>(); //TODO: Reduce this? for (Set fileArray : filesToScan) { for (File file : fileArray) { filesToScanReal.addAll(Utils.getFilesRecursive(file)); //TODO: Inline this, hash files as they are found } } //Pre fileHashesMD5.clear(); fileHashesSHA1.clear(); fileHashesSHA256.clear(); publishProgress("\t" + context.getString(R.string.main_files_pending_scan, NumberFormat.getInstance().format(filesToScanReal.size())) + "\n", true); Database.loadDatabase(context, false, Database.signatureDatabases); int delayCount = 0; if (Database.areDatabasesAvailable()) { while (!Database.isDatabaseLoaded() && delayCount <= 90) { try { Thread.sleep(1000); delayCount++; if ((delayCount % 15) == 0) { publishProgress("\t" + context.getString(R.string.main_database_loading), true); } } catch (InterruptedException e) { e.printStackTrace(); } } } if (Database.isDatabaseLoaded()) { publishProgress("\t" + context.getString(R.string.main_database_loaded, NumberFormat.getInstance().format(Database.signaturesCount)) + "\n", true); //Perform a self-test if (Database.selfTest()) { publishProgress("\t" + context.getString(R.string.self_test_result_success), true); } else { publishProgress("\t" + context.getString(R.string.self_test_result_failure), true); } //Get file hashes publishProgress("\t" + context.getString(R.string.main_hashing_files), true); publishProgress("\t", true); int fileScannedCount = 0; int percentIncrement = (filesToScanReal.size() / 20); if (percentIncrement < 1) { //Prevent divide by zero percentIncrement = 1; } String spinnerCur = " ~ "; long totalBytesHashed = 0; long hashStartTime = SystemClock.elapsedRealtime(); for (File file : filesToScanReal) { if (this.isCancelled()) { //Allow quicker cancels //publishProgress("\t" + context.getString(R.string.main_cancelled_scan), true); running = false; return null; } totalBytesHashed += file.length(); getFileHashes(file); filesToScanReal.remove(file); fileScannedCount++; if ((fileScannedCount % percentIncrement) == 0) { publishProgress(spinnerCur, true); if (spinnerCur.equals(" = ")) { spinnerCur = " ~ "; } else { spinnerCur = " = "; } } //Log.d("Hypatia", "Scanning " + file); } filesToScanReal.clear(); publishProgress("\n\t" + context.getString(R.string.main_hashing_done) + "\n", true); //Check the hashes checkSignature("MD5", fileHashesMD5, Database.signaturesMD5); checkSignature("SHA-1", fileHashesSHA1, Database.signaturesSHA1); checkSignature("SHA-256", fileHashesSHA256, Database.signaturesSHA256); if (Database.signaturesMD5Extended != null) { checkSignature("MD5 Extended", fileHashesMD5, Database.signaturesMD5Extended); } //Scan result if (startCount == amtMatchedFiles) { publishProgress("\n\t" + context.getString(R.string.detections_none) + "\n", true); } else { publishProgress("\n\t" + context.getString(R.string.detections_found) + "\n", true); } //Post fileHashesMD5.clear(); fileHashesSHA1.clear(); fileHashesSHA256.clear(); Utils.FILES_SCANNED.getAndAdd(fileScannedCount); if (userFacing || Utils.FILES_SCANNED.get() % 40 == 0) { System.gc(); //GC can be expensive, don't run it too often. } if (userFacing) { long secondsSpent = ((SystemClock.elapsedRealtime() - scanStartTime) / 1000L); long secondsSpentHashing = ((SystemClock.elapsedRealtime() - hashStartTime) / 1000L); long MBS = 0; if (secondsSpentHashing > 0) { MBS = totalBytesHashed / 1000 / 1000 / secondsSpentHashing; } publishProgress(context.getString(R.string.main_scanning_done, String.valueOf(secondsSpent), String.valueOf(MBS)) + "\n\n\n\n", true); } } else { publishProgress("\t" + context.getString(R.string.main_no_database_available), true); } running = false; return null; } @Override protected final void onProgressUpdate(Object... objects) { String hash = null; if (objects.length == 3) { hash = (String) objects[2]; } logResult((String) objects[0], hash); } private void checkSignature(String hashType, HashMap signaturesToCheck, BloomFilter signatureDatabase) { if (Database.isDatabaseLoaded() && signatureDatabase != null) { for (Map.Entry file : signaturesToCheck.entrySet()) { if (!file.getValue().equals("d41d8cd98f00b204e9800998ecf8427e") && !file.getValue().equals("da39a3ee5e6b4b0d3255bfef95601890afd80709") && !file.getValue().equals("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")) { if (signatureDatabase.mightContain(file.getValue())) { amtMatchedFiles++; //Log.d("Hypatia", "Match: " + fileHashesSHA256.get(file.getKey())); publishProgress("Potential match in " + file.getKey().toString().replaceAll(Environment.getExternalStorageDirectory().toString(), "~"), false, fileHashesSHA256.get(file.getKey())); } else { //Log.d("Hypatia", "No match for " + file.getValue()); } } } publishProgress("\t" + context.getString(R.string.main_hash_scan_done, hashType) + "\n", true); } else { publishProgress("\t" + context.getString(R.string.main_no_database_available) + "\n", true); } } private void getFileHashes(File file) { try { InputStream fis = new FileInputStream(file); byte[] buffer = new byte[4096]; int numRead; MessageDigest digestMD5 = MessageDigest.getInstance("MD5"); MessageDigest digestSHA1 = MessageDigest.getInstance("SHA-1"); MessageDigest digestSHA256 = MessageDigest.getInstance("SHA-256"); do { numRead = fis.read(buffer); if (numRead > 0) { digestMD5.update(buffer, 0, numRead); digestSHA1.update(buffer, 0, numRead); digestSHA256.update(buffer, 0, numRead); } } while (numRead != -1); fis.close(); fileHashesMD5.put(file, String.format("%032x", new BigInteger(1, digestMD5.digest())).toLowerCase()); fileHashesSHA1.put(file, String.format("%040x", new BigInteger(1, digestSHA1.digest())).toLowerCase()); fileHashesSHA256.put(file, String.format("%064x", new BigInteger(1, digestSHA256.digest())).toLowerCase()); } catch (Exception e) { e.printStackTrace(); } } }