mirror of
https://github.com/hyugogirubato/KeyDive.git
synced 2026-07-15 18:40:02 +02:00
release v3.0.0
This commit is contained in:
+5
-5
@@ -1,7 +1,7 @@
|
||||
from .core import Core
|
||||
from .adb import ADB
|
||||
from .cdm import Cdm
|
||||
from .vendor import Vendor
|
||||
from .keybox import Keybox
|
||||
from .adb.remote import Remote
|
||||
from .adb.vendor import Vendor
|
||||
from .drm.cdm import Cdm
|
||||
from .drm.keybox import KeyBox
|
||||
|
||||
__version__ = "2.2.1"
|
||||
__version__ = '3.0.0'
|
||||
|
||||
+60
-173
@@ -1,208 +1,95 @@
|
||||
import argparse
|
||||
import logging
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import coloredlogs
|
||||
from rich_argparse import RichHelpFormatter
|
||||
|
||||
import keydive
|
||||
|
||||
from keydive.adb import ADB
|
||||
from keydive.cdm import Cdm
|
||||
from keydive.constants import CDM_VENDOR_API, DRM_PLAYER
|
||||
from keydive.core import Core
|
||||
|
||||
|
||||
def configure_logging(path: Path = None, verbose: bool = False) -> Optional[Path]:
|
||||
"""
|
||||
Configures logging for the application.
|
||||
|
||||
Parameters:
|
||||
path (Path, optional): The directory to store log files.
|
||||
verbose (bool, optional): Flag to enable detailed debug logging.
|
||||
|
||||
Returns:
|
||||
Path: The path of the log file, or None if no log file is created.
|
||||
"""
|
||||
# Set up the root logger with the desired logging level
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
# Clear any existing handlers (optional, to avoid duplicate logs if reconfiguring)
|
||||
if root_logger.hasHandlers():
|
||||
root_logger.handlers.clear()
|
||||
|
||||
file_path = None
|
||||
if path:
|
||||
# Ensure the log directory exists
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a file handler
|
||||
file_path = path / ("keydive_%s.log" % datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
|
||||
file_path = file_path.resolve(strict=False)
|
||||
file_handler = logging.FileHandler(file_path)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# Set log formatting
|
||||
formatter = logging.Formatter(
|
||||
fmt="%(asctime)s [%(levelname).1s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Add the file handler to the root logger
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Configure coloredlogs for console output
|
||||
coloredlogs.install(
|
||||
fmt="%(asctime)s [%(levelname).1s] %(name)s: %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
level=logging.DEBUG if verbose else logging.INFO,
|
||||
logger=root_logger
|
||||
)
|
||||
return file_path
|
||||
from keydive.utils import configure_logging
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Main entry point for the KeyDive application.
|
||||
parser = ArgumentParser(
|
||||
description='Extract Widevine CDM components from an Android device.',
|
||||
formatter_class=RichHelpFormatter)
|
||||
|
||||
This application extracts Widevine L3 keys from an Android device.
|
||||
It supports device management via ADB and allows hooking into Widevine processes.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="Extract Widevine L3 keys from an Android device.")
|
||||
global_group = parser.add_argument_group('Global Options')
|
||||
global_group.add_argument('-s', '--serial', type=str, metavar='<serial>', help='ADB serial number of the target Android device.')
|
||||
global_group.add_argument('-d', '--delay', type=float, metavar='<delay>', default=1.0, help='Delay in seconds between process status checks. (default: 1.0)')
|
||||
global_group.add_argument('-v', '--verbose', action='store_true', help='Enable detailed logging for debugging.')
|
||||
global_group.add_argument('-l', '--log', type=Path, metavar='<dir>', help='Directory to save log files.')
|
||||
global_group.add_argument('-V', '--version', action='store_true', help='Show tool version and exit.')
|
||||
|
||||
# Global arguments for the application
|
||||
group_global = parser.add_argument_group("Global")
|
||||
group_global.add_argument('-d', '--device', required=False, type=str, metavar="<id>", help="Specify the target Android device ID for ADB connection.")
|
||||
group_global.add_argument('-v', '--verbose', required=False, action="store_true", help="Enable verbose logging for detailed debug output.")
|
||||
group_global.add_argument('-l', '--log', required=False, type=Path, metavar="<dir>", help="Directory to store log files.")
|
||||
group_global.add_argument('--delay', required=False, type=float, metavar="<delay>", default=1, help="Delay (in seconds) between process checks.")
|
||||
group_global.add_argument('--version', required=False, action="store_true", help="Display KeyDive version information.")
|
||||
cdm_group = parser.add_argument_group('CDM Extraction')
|
||||
cdm_group.add_argument('-o', '--output', type=Path, default='device', metavar='<dir>', help='Directory to store extracted CDM files. (default: ./device)')
|
||||
cdm_group.add_argument('-w', '--wvd', action='store_true', help='Export data in pywidevine-compatible WVD format.')
|
||||
cdm_group.add_argument('-k', '--keybox', action='store_true', help='Export Widevine keybox if available on the device.')
|
||||
cdm_group.add_argument('-a', '--auto', choices=['web', 'player'], metavar='<type>', help='Automatically launch a DRM playback test. ("web" or "player")')
|
||||
|
||||
# Arguments specific to the CDM (Content Decryption Module)
|
||||
group_cdm = parser.add_argument_group("Cdm")
|
||||
group_cdm.add_argument('-o', '--output', required=False, type=Path, default=Path("device"), metavar="<dir>", help="Output directory for extracted data.")
|
||||
group_cdm.add_argument('-w', '--wvd', required=False, action="store_true", help="Generate a pywidevine WVD device file.")
|
||||
group_cdm.add_argument('-s', '--skip', required=False, action="store_true", help="Skip auto-detection of the private function.")
|
||||
group_cdm.add_argument('-a', '--auto', required=False, action="store_true", help="Automatically start the Bitmovin web player.")
|
||||
group_cdm.add_argument('-p', '--player', required=False, action="store_true", help="Install and start the Kaltura app automatically.")
|
||||
|
||||
# Advanced options
|
||||
group_advanced = parser.add_argument_group("Advanced")
|
||||
group_advanced.add_argument('-f', '--functions', required=False, type=Path, metavar="<file>", help="Path to Ghidra XML functions file.")
|
||||
group_advanced.add_argument('-k', '--keybox', required=False, action="store_true", help="Enable export of the Keybox data if it is available.")
|
||||
group_advanced.add_argument('--challenge', required=False, type=Path, metavar="<file>", help="Path to unencrypted challenge for extracting client ID.")
|
||||
group_advanced.add_argument('--private-key', required=False, type=Path, metavar="<file>", help="Path to private key for extracting client ID.")
|
||||
advanced_group = parser.add_argument_group('Advanced Options')
|
||||
advanced_group.add_argument('--no-detect', action='store_false', help='Disable automatic detection of OEM private key function.')
|
||||
advanced_group.add_argument('--no-disabler', action='store_false', help='Disable liboemcrypto-disabler module (patches memory protection).')
|
||||
advanced_group.add_argument('--no-stop', action='store_false', help='Do not stop once minimum CDM data is intercepted.')
|
||||
advanced_group.add_argument('--unencrypt', action='store_true', help='Force the license challenge to keep client ID data unencrypted.')
|
||||
advanced_group.add_argument('--symbols', type=Path, metavar='<symbols>', help='Path to Ghidra-generated XML symbol file for function mapping.')
|
||||
advanced_group.add_argument('--challenge', action='append', type=Path, metavar='<challenge>', help='Protobuf challenge file(s) captured via MITM proxy.')
|
||||
advanced_group.add_argument('--rsa-key', action='append', type=Path, metavar='<rsa-key>', help='RSA private key(s) in PEM or DER format for client ID decryption.')
|
||||
advanced_group.add_argument('--aes-key', action='append', type=str, metavar='<aes-key>', help='AES key(s) in hex, base64, or file form for decrypting keybox data.')
|
||||
|
||||
# Parse command-line arguments
|
||||
args = parser.parse_args()
|
||||
|
||||
# Handle version display
|
||||
# Handle version flag early and exit
|
||||
if args.version:
|
||||
print(f"KeyDive {keydive.__version__}")
|
||||
exit(0)
|
||||
print(f'KeyDive {keydive.__version__}')
|
||||
return
|
||||
|
||||
# Configure logging (file and console)
|
||||
# Set up logging (to file if specified, otherwise stdout)
|
||||
log_path = configure_logging(path=args.log, verbose=args.verbose)
|
||||
logger = logging.getLogger("KeyDive")
|
||||
logger.info("Version: %s", keydive.__version__)
|
||||
logger = logging.getLogger('keydive')
|
||||
logger.info('Version: %s', keydive.__version__)
|
||||
|
||||
try:
|
||||
# Connect to the specified Android device
|
||||
adb = ADB(device=args.device)
|
||||
# Initialize core logic with device serial, Ghidra symbols, and feature toggles
|
||||
core = Core(
|
||||
serial=args.serial,
|
||||
symbols=args.symbols,
|
||||
detect=args.no_detect,
|
||||
disabler=args.no_disabler,
|
||||
unencrypt=args.unencrypt
|
||||
)
|
||||
|
||||
# Initialize Cdm instance for content decryption module (with optional arguments)
|
||||
cdm = Cdm(keybox=args.keybox)
|
||||
if args.challenge:
|
||||
cdm.set_challenge(data=args.challenge)
|
||||
if args.private_key:
|
||||
cdm.set_private_key(data=args.private_key, name=None)
|
||||
# Provide challenge files if intercepted (optional, used for verification/analysis)
|
||||
core.cdm.set_challenge(data=args.challenge)
|
||||
|
||||
# Initialize Core instance for interacting with the device
|
||||
core = Core(adb=adb, cdm=cdm, functions=args.functions, skip=args.skip)
|
||||
# Provide RSA private keys (PEM or DER format) for client ID decryption
|
||||
core.cdm.set_private_key(data=args.rsa_key, name=None)
|
||||
|
||||
# Setup actions based on user arguments (for DRM player, Bitmovin player, etc.)
|
||||
if args.player:
|
||||
package = DRM_PLAYER["package"]
|
||||
# Provide AES key(s) for decrypting the keybox or related data
|
||||
core.cdm.set_device_aes_key(data=args.aes_key)
|
||||
|
||||
# Check if the application is already installed
|
||||
installed = package in adb.list_applications(user=True, system=False)
|
||||
if not installed:
|
||||
logger.debug("Application %s not found. Installing...", package)
|
||||
installed = adb.install_application(path=DRM_PLAYER["path"], url=DRM_PLAYER["url"])
|
||||
# Optionally launch a DRM playback test (web-based or player-based)
|
||||
if args.auto:
|
||||
core.launch(action=args.auto)
|
||||
|
||||
# Skip starting the application if installation failed
|
||||
if installed:
|
||||
# Start the application
|
||||
logger.info("Starting application: %s", package)
|
||||
adb.start_application(package)
|
||||
elif args.auto:
|
||||
logger.info("Opening the Bitmovin web player...")
|
||||
adb.open_url("https://bitmovin.com/demos/drm")
|
||||
logger.info("Setup completed")
|
||||
|
||||
# Process watcher loop: continuously checks for Widevine processes
|
||||
logger.info("Watcher delay: %ss" % args.delay)
|
||||
current = None # Variable to track the current Widevine process
|
||||
while core.running:
|
||||
# Check if for current process data has been exported
|
||||
if current and cdm.export(args.output, args.wvd):
|
||||
raise KeyboardInterrupt # Stop if export is complete
|
||||
|
||||
# Get the currently running Widevine processes
|
||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146788792
|
||||
processes = {
|
||||
key: (name, pid)
|
||||
for name, pid in adb.enumerate_processes().items()
|
||||
for key in CDM_VENDOR_API.keys()
|
||||
if key in name or key.replace("-service", "-service-lazy") in name
|
||||
}
|
||||
|
||||
if not processes:
|
||||
raise EnvironmentError("Unable to detect Widevine, refer to https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#drm-info")
|
||||
|
||||
# Check if the current process has changed
|
||||
if current and current not in [v[1] for v in processes.values()]:
|
||||
logger.warning("Widevine process has changed")
|
||||
current = None
|
||||
|
||||
# If current process not found, attempt to hook into the detected processes
|
||||
if not current:
|
||||
logger.debug("Analysing...")
|
||||
|
||||
for key, (name, pid) in processes.items():
|
||||
if current:
|
||||
break
|
||||
for vendor in CDM_VENDOR_API[key]:
|
||||
if core.hook_process(pid=pid, vendor=vendor):
|
||||
logger.info("Process: %s (%s)", pid, name)
|
||||
current = pid
|
||||
break
|
||||
elif not core.running:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
if current:
|
||||
logger.info("Successfully hooked")
|
||||
else:
|
||||
logger.warning("Widevine library not found, searching...")
|
||||
|
||||
# Delay before next iteration
|
||||
time.sleep(args.delay)
|
||||
# Begin monitoring the device to extract Widevine CDM data
|
||||
# Stops automatically if sufficient data is collected, unless --no-stop is specified
|
||||
core.watchdog(output=args.output, delay=args.delay, auto_stop=args.no_stop, wvd=args.wvd, keybox=args.keybox)
|
||||
except KeyboardInterrupt:
|
||||
# Graceful exit on user interrupt (Ctrl+C)
|
||||
pass
|
||||
except Exception as e:
|
||||
# Log any unhandled exception, include traceback if verbose logging is enabled
|
||||
logger.critical(e, exc_info=args.verbose)
|
||||
|
||||
# Final logging and exit
|
||||
# Log the path to the log file if it was created
|
||||
if log_path:
|
||||
logger.info("Log file: %s" % log_path)
|
||||
logger.info("Exiting")
|
||||
logger.info('Log file: %s' % log_path)
|
||||
|
||||
logger.info('Exiting')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
-287
@@ -1,287 +0,0 @@
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
import requests
|
||||
|
||||
from frida.core import Device
|
||||
|
||||
# Suppress urllib3 warnings
|
||||
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
|
||||
|
||||
|
||||
def shell(prompt: list) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Executes a shell command and returns the result.
|
||||
|
||||
Parameters:
|
||||
prompt (list): The command to execute as a list of strings.
|
||||
|
||||
Returns:
|
||||
subprocess.CompletedProcess: The result containing return code, stdout, and stderr.
|
||||
"""
|
||||
prompt = list(map(str, prompt)) # Ensure all command parts are strings
|
||||
# logging.getLogger("Shell").debug(" ".join(prompt))
|
||||
return subprocess.run(prompt, capture_output=True) # Run the command and capture output
|
||||
|
||||
|
||||
class ADB:
|
||||
"""
|
||||
Class for managing interactions with the Android device via ADB.
|
||||
"""
|
||||
|
||||
def __init__(self, device: str = None, timeout: int = 5):
|
||||
"""
|
||||
Initializes ADB connection to the device.
|
||||
|
||||
Parameters:
|
||||
device (str, optional): Device ID to connect to, defaults to the first USB device.
|
||||
timeout (int, optional): Timeout for connection in seconds. Defaults to 5.
|
||||
|
||||
Raises:
|
||||
EnvironmentError: If ADB is not found in the system path.
|
||||
Exception: If connection to the device fails.
|
||||
"""
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
|
||||
# Ensure ADB is available
|
||||
if not shutil.which("adb"):
|
||||
raise EnvironmentError(
|
||||
"ADB is not recognized as an environment variable. "
|
||||
"Ensure ADB is installed and refer to the documentation: "
|
||||
"https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge"
|
||||
)
|
||||
|
||||
# Start the ADB server if not already running
|
||||
sp = shell(['adb', 'start-server'])
|
||||
if sp.returncode != 0:
|
||||
self.logger.warning("ADB server startup failed (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||
|
||||
# Connect to device (or default to the first USB device)
|
||||
try:
|
||||
self.device: Device = frida.get_device(id=device, timeout=timeout) if device else frida.get_usb_device(timeout=timeout)
|
||||
self.logger.info("Connected to device: %s (%s)", self.device.name, self.device.id)
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to connect to device: %s", e)
|
||||
raise e
|
||||
|
||||
self.prompt = ['adb', '-s', self.device.id, 'shell']
|
||||
|
||||
# Retrieve and log device properties
|
||||
properties = self.device_properties()
|
||||
if properties:
|
||||
self.logger.info("SDK API: %s", properties.get("ro.build.version.sdk", "Unknown"))
|
||||
self.logger.info("ABI CPU: %s", properties.get("ro.product.cpu.abi", "Unknown"))
|
||||
else:
|
||||
self.logger.warning("No device properties retrieved")
|
||||
|
||||
def device_properties(self) -> dict:
|
||||
"""
|
||||
Retrieves system properties from the device.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping property keys to their corresponding values.
|
||||
"""
|
||||
# https://source.android.com/docs/core/architecture/configuration/add-system-properties?#shell-commands
|
||||
properties = {}
|
||||
|
||||
# Execute the shell command to retrieve device properties
|
||||
sp = shell([*self.prompt, 'getprop'])
|
||||
if sp.returncode != 0:
|
||||
self.logger.error("Failed to retrieve device properties (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||
return properties
|
||||
|
||||
# Parse the output and cast values accordingly
|
||||
for line in sp.stdout.decode("utf-8").splitlines():
|
||||
match = re.match(r"\[(.*?)\]: \[(.*?)\]", line)
|
||||
if match:
|
||||
key, value = match.groups()
|
||||
|
||||
# Cast numeric and boolean values where appropriate
|
||||
if value.isdigit():
|
||||
value = int(value)
|
||||
elif value.lower() in ("true", "false"):
|
||||
value = value.lower() == "true"
|
||||
|
||||
properties[key] = value
|
||||
|
||||
return properties
|
||||
|
||||
def list_applications(self, user: bool = True, system: bool = False) -> dict:
|
||||
"""
|
||||
Lists installed applications on the device, with optional filters for user/system apps.
|
||||
|
||||
Parameters:
|
||||
user (bool, optional): Include user-installed apps. Defaults to True.
|
||||
system (bool, optional): Include system apps. Defaults to False.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of application packages and their file paths.
|
||||
"""
|
||||
applications = {}
|
||||
|
||||
# Validate input; return empty dict if no filter is set
|
||||
if not user and not system:
|
||||
return applications
|
||||
|
||||
# Set the appropriate shell command based on user/system filters
|
||||
prompt = [*self.prompt, 'pm', 'list', 'packages', '-f']
|
||||
if user and not system:
|
||||
prompt.append("-3")
|
||||
elif not user and system:
|
||||
prompt.append("-s")
|
||||
|
||||
# Execute the shell command to list applications
|
||||
sp = shell(prompt)
|
||||
if sp.returncode != 0:
|
||||
self.logger.error("Failed to retrieve app list (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||
return applications
|
||||
|
||||
# Parse and add applications to the dictionary
|
||||
for line in sp.stdout.decode("utf-8").splitlines():
|
||||
try:
|
||||
path, package = line.strip().split(":", 1)[1].rsplit("=", 1)
|
||||
applications[package] = path
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return applications
|
||||
|
||||
def start_application(self, package: str) -> bool:
|
||||
"""
|
||||
Starts an application by its package name.
|
||||
|
||||
Parameters:
|
||||
package (str): The package name of the application.
|
||||
|
||||
Returns:
|
||||
bool: True if the app was started successfully, False otherwise.
|
||||
"""
|
||||
# Get package information using dumpsys
|
||||
sp = shell([*self.prompt, 'dumpsys', 'package', package])
|
||||
lines = sp.stdout.decode("utf-8").splitlines()
|
||||
|
||||
# Remove empty lines to ensure backwards compatibility
|
||||
lines = [l.strip() for l in lines if l.strip()]
|
||||
|
||||
# Look for MAIN activity to identify entry point
|
||||
for i, line in enumerate(lines):
|
||||
if "android.intent.action.MAIN" in line:
|
||||
match = re.search(fr"({package}/[^ ]+)", lines[i + 1])
|
||||
if match:
|
||||
# Start the application by its main activity
|
||||
main_activity = match.group()
|
||||
sp = shell([*self.prompt, 'am', 'start', '-n', main_activity])
|
||||
if sp.returncode == 0:
|
||||
return True
|
||||
|
||||
self.logger.error("Failed to start app %s (Error: %s)", package, sp.stdout.decode("utf-8").strip())
|
||||
break
|
||||
|
||||
self.logger.error("Package %s not found or no MAIN intent", package)
|
||||
return False
|
||||
|
||||
def enumerate_processes(self) -> dict:
|
||||
"""
|
||||
Lists running processes and maps process names to their PIDs.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary of process names and corresponding PIDs.
|
||||
"""
|
||||
# https://github.com/frida/frida/issues/1225#issuecomment-604181822
|
||||
processes = {}
|
||||
|
||||
# Attempt to get the list of processes using the 'ps -A' command
|
||||
prompt = [*self.prompt, 'ps']
|
||||
sp = shell([*prompt, '-A'])
|
||||
lines = sp.stdout.decode("utf-8").splitlines()
|
||||
|
||||
# If the output has less than 10 lines, retry with a simpler 'ps' command
|
||||
if len(lines) < 10:
|
||||
sp = shell(prompt)
|
||||
if sp.returncode != 0:
|
||||
self.logger.error("Failed to execute ps command (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||
return processes
|
||||
lines = sp.stdout.decode("utf-8").splitlines()
|
||||
|
||||
# Iterate through lines starting from the second line (skipping header)
|
||||
for line in lines[1:]:
|
||||
try:
|
||||
parts = line.split() # USER,PID,PPID,VSZ,RSS,WCHAN,ADDR,S,NAME
|
||||
pid = int(parts[1]) # Extract PID
|
||||
name = " ".join(parts[8:]).strip() # Extract process name
|
||||
|
||||
# Handle cases where process name might be in brackets (e.g., kernel threads)
|
||||
name = name if name.startswith("[") else Path(name).name
|
||||
processes[name] = pid
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return processes
|
||||
|
||||
def install_application(self, path: Path = None, url: str = None) -> bool:
|
||||
"""
|
||||
Installs an application on the device either from a local file or by downloading from a URL.
|
||||
|
||||
Parameters:
|
||||
path (Path, optional): The local file path of the APK to install. Defaults to None.
|
||||
url (str, optional): The URL to download the APK from. Defaults to None.
|
||||
|
||||
Returns:
|
||||
bool: True if the installation was successful, False otherwise.
|
||||
"""
|
||||
# Prepare the shell command for installation
|
||||
prompt = [*self.prompt[:-1], 'install']
|
||||
|
||||
# Install from a local file path if a valid path is provided
|
||||
if path and path.is_file():
|
||||
sp = shell([*prompt, path]) # Run the installation command with the local file path
|
||||
if sp.returncode == 0:
|
||||
return True
|
||||
self.logger.error("Installation failed for local path: %s (Error: %s)", path, sp.stdout.decode("utf-8").strip())
|
||||
|
||||
# If URL is provided, attempt to download the APK and install it
|
||||
status = False
|
||||
if url:
|
||||
file = Path("tmp.apk") # Temporary file to store the downloaded APK
|
||||
try:
|
||||
# Download the APK from the provided URL
|
||||
r = requests.get(url, headers={"Accept": "*/*", "User-Agent": "KeyDive/ADB"})
|
||||
r.raise_for_status()
|
||||
|
||||
# Save the downloaded APK to a temporary file
|
||||
file.write_bytes(r.content)
|
||||
|
||||
# Attempt installation from the downloaded APK
|
||||
status = self.install_application(path=file)
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to download application from URL: %s (Error: %s)", url, e)
|
||||
file.unlink(missing_ok=True) # Clean up the temporary file, even if there was an error
|
||||
|
||||
return status
|
||||
|
||||
def open_url(self, url: str) -> bool:
|
||||
"""
|
||||
Opens a specified URL on the device.
|
||||
|
||||
Parameters:
|
||||
url (str): The URL to be opened on the device.
|
||||
|
||||
Returns:
|
||||
bool: True if the URL was successfully opened, False otherwise.
|
||||
"""
|
||||
# Execute the shell command to open the URL using the Android 'am' (Activity Manager) command.
|
||||
sp = shell([*self.prompt, 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url])
|
||||
|
||||
# Check the result of the command execution and log if there is an error
|
||||
if sp.returncode != 0:
|
||||
self.logger.error("URL open failed for: %s (Return: %s)", url, sp.stdout.decode("utf-8").strip())
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ("ADB",)
|
||||
@@ -0,0 +1,171 @@
|
||||
from pathlib import Path
|
||||
|
||||
# https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:bionic/libc/libc.map.txt
|
||||
NATIVE_C_API = (
|
||||
'__assert', '__assert2', '__atomic_cmpxchg', '__atomic_dec', '__atomic_inc', '__atomic_swap', '__b64_ntop',
|
||||
'__b64_pton', '__cmsg_nxthdr', '__connect', '__ctype_get_mb_cur_max', '__cxa_atexit', '__cxa_finalize',
|
||||
'__cxa_thread_atexit_impl', '__dn_comp', '__dn_count_labels', '__dn_skipname', '__epoll_pwait', '__errno', '__exit',
|
||||
'__fadvise64', '__fbufsize', '__fcntl64', '__FD_CLR_chk', '__FD_ISSET_chk', '__FD_SET_chk', '__fgets_chk', '__flbf',
|
||||
'__fp_nquery', '__fp_query', '__fpclassify', '__fpclassifyd', '__fpclassifyf', '__fpclassifyl', '__fpending',
|
||||
'__fpurge', '__freadable', '__fsetlocking', '__fstatfs64', '__fwritable', '__get_h_errno', '__getcpu', '__getcwd',
|
||||
'__getpid', '__getpriority', '__gnu_basename', '__gnu_strerror_r', '__hostalias', '__ioctl', '__isfinite',
|
||||
'__isfinitef', '__isfinitel', '__isinf', '__isinff', '__isinfl', '__isnan', '__isnanf', '__isnanl', '__isnormal',
|
||||
'__isnormalf', '__isnormall', '__isthreaded', '__libc_current_sigrtmax', '__libc_current_sigrtmin', '__libc_init',
|
||||
'__llseek', '__loc_aton', '__loc_ntoa', '__memchr_chk', '__memcpy_chk', '__memmove_chk', '__memrchr_chk',
|
||||
'__memset_chk', '__mmap2', '__ns_format_ttl', '__ns_get16', '__ns_get32', '__ns_initparse', '__ns_makecanon',
|
||||
'__ns_msg_getflag', '__ns_name_compress', '__ns_name_ntol', '__ns_name_ntop', '__ns_name_pack', '__ns_name_pton',
|
||||
'__ns_name_rollback', '__ns_name_skip', '__ns_name_uncompress', '__ns_name_unpack', '__ns_parserr', '__ns_put16',
|
||||
'__ns_put32', '__ns_samename', '__ns_skiprr', '__ns_sprintrr', '__ns_sprintrrf', '__open_2', '__openat',
|
||||
'__openat_2', '__p_cdname', '__p_cdnname', '__p_class', '__p_class_syms', '__p_fqname', '__p_fqnname', '__p_option',
|
||||
'__p_query', '__p_rcode', '__p_secstodate', '__p_time', '__p_type', '__p_type_syms', '__poll_chk', '__ppoll',
|
||||
'__ppoll_chk', '__ppoll64_chk', '__pread64_chk', '__pread_chk', '__progname', '__pselect6', '__pthread_cleanup_pop',
|
||||
'__pthread_cleanup_push', '__ptrace', '__putlong', '__putshort', '__read_chk', '__readlink_chk', '__readlinkat_chk',
|
||||
'__reboot', '__recvfrom_chk', '__register_atfork', '__res_close', '__res_dnok', '__res_hnok', '__res_hostalias',
|
||||
'__res_isourserver', '__res_mailok', '__res_nameinquery', '__res_nclose', '__res_ninit', '__res_nmkquery',
|
||||
'__res_nquery', '__res_nquerydomain', '__res_nsearch', '__res_nsend', '__res_ownok', '__res_queriesmatch',
|
||||
'__res_querydomain', '__res_send', '__res_send_setqhook', '__res_send_setrhook', '__rt_sigaction',
|
||||
'__rt_sigpending', '__rt_sigprocmask', '__rt_sigsuspend', '__rt_sigtimedwait', '__sched_cpualloc',
|
||||
'__sched_cpucount', '__sched_cpufree', '__sched_getaffinity', '__set_thread_area', '__set_tid_address', '__set_tls',
|
||||
'__sF', '__sigaction', '__snprintf_chk', '__socket', '__sprintf_chk', '__stack_chk_fail', '__stack_chk_guard',
|
||||
'__statfs64', '__stpcpy_chk', '__stpncpy_chk', '__stpncpy_chk2', '__strcat_chk', '__strchr_chk', '__strcpy_chk',
|
||||
'__strlcat_chk', '__strlcpy_chk', '__strlen_chk', '__strncat_chk', '__strncpy_chk', '__strncpy_chk2',
|
||||
'__strrchr_chk', '__sym_ntop', '__sym_ntos', '__sym_ston', '__system_property_area_serial',
|
||||
'__system_property_find', '__system_property_find_nth', '__system_property_foreach', '__system_property_get',
|
||||
'__system_property_read', '__system_property_serial', '__system_property_set', '__timer_create', '__timer_delete',
|
||||
'__timer_getoverrun', '__timer_gettime', '__timer_settime', '__umask_chk', '__vsnprintf_chk', '__vsprintf_chk',
|
||||
'__waitid', '_ctype_', '_Exit', '_exit', '_flushlbf', '_getlong', '_getshort', '_longjmp',
|
||||
'_resolv_delete_cache_for_net', '_resolv_flush_cache_for_net', '_resolv_set_nameservers_for_net', '_setjmp',
|
||||
'_tolower', '_tolower_tab_', '_toupper', '_toupper_tab_', 'abort', 'abs', 'accept', 'accept4', 'access', 'acct',
|
||||
'alarm', 'alphasort', 'alphasort64', 'android_set_abort_message', 'arc4random', 'arc4random_buf',
|
||||
'arc4random_uniform', 'asctime', 'asctime64', 'asctime64_r', 'asctime_r', 'asprintf', 'at_quick_exit', 'atof',
|
||||
'atoi', 'atol', 'atoll', 'basename', 'basename_r', 'bind', 'bindresvport', 'brk', 'bsearch', 'btowc', 'c16rtomb',
|
||||
'c32rtomb', 'cacheflush', 'calloc', 'capget', 'capset', 'cfgetispeed', 'cfgetospeed', 'cfmakeraw', 'cfsetispeed',
|
||||
'cfsetospeed', 'cfsetspeed', 'chdir', 'chmod', 'chown', 'chroot', 'clearenv', 'clearerr', 'clearerr_unlocked',
|
||||
'clock', 'clock_getcpuclockid', 'clock_getres', 'clock_gettime', 'clock_nanosleep', 'clock_settime', 'clone',
|
||||
'close', 'closedir', 'closelog', 'connect', 'creat', 'creat64', 'ctime', 'ctime64', 'ctime64_r', 'ctime_r',
|
||||
'daemon', 'daylight', 'delete_module', 'difftime', 'dirfd', 'dirname', 'dirname_r', 'div', 'dn_expand', 'dprintf',
|
||||
'drand48', 'dup', 'dup2', 'dup3', 'duplocale', 'endmntent', 'endservent', 'endutent', 'environ', 'epoll_create',
|
||||
'epoll_create1', 'epoll_ctl', 'epoll_pwait', 'epoll_wait', 'erand48', 'err', 'error', 'error_at_line',
|
||||
'error_message_count', 'error_one_per_line', 'error_print_progname', 'errx', 'ether_aton', 'ether_aton_r',
|
||||
'ether_ntoa', 'ether_ntoa_r', 'eventfd', 'eventfd_read', 'eventfd_write', 'execl', 'execle', 'execlp', 'execv',
|
||||
'execve', 'execvp', 'execvpe', 'exit', 'faccessat', 'fallocate', 'fallocate64', 'fchdir', 'fchmod', 'fchmodat',
|
||||
'fchown', 'fchownat', 'fclose', 'fcntl', 'fdatasync', 'fdopen', 'fdopendir', 'fdprintf', 'feof', 'feof_unlocked',
|
||||
'ferror', 'ferror_unlocked', 'fflush', 'ffs', 'fgetc', 'fgetln', 'fgetpos', 'fgets', 'fgetwc', 'fgetws',
|
||||
'fgetxattr', 'fileno', 'flistxattr', 'flock', 'flockfile', 'fmemopen', 'fnmatch', 'fopen', 'fork', 'forkpty',
|
||||
'fpathconf', 'fprintf', 'fpurge', 'fputc', 'fputs', 'fputwc', 'fputws', 'fread', 'free', 'freeaddrinfo',
|
||||
'freelocale', 'fremovexattr', 'freopen', 'fscanf', 'fseek', 'fseeko', 'fsetpos', 'fsetxattr', 'fstat', 'fstat64',
|
||||
'fstatat', 'fstatat64', 'fstatfs', 'fstatfs64', 'fstatvfs', 'fstatvfs64', 'fsync', 'ftell', 'ftello', 'ftok',
|
||||
'ftruncate', 'ftruncate64', 'ftrylockfile', 'fts_children', 'fts_close', 'fts_open', 'fts_read', 'fts_set', 'ftw',
|
||||
'ftw64', 'funlockfile', 'funopen', 'futimens', 'fwide', 'fwprintf', 'fwrite', 'fwscanf', 'gai_strerror',
|
||||
'get_avphys_pages', 'get_nprocs', 'get_nprocs_conf', 'get_phys_pages', 'getaddrinfo', 'getauxval', 'getc',
|
||||
'getc_unlocked', 'getchar', 'getchar_unlocked', 'getcwd', 'getdelim', 'getegid', 'getenv', 'geteuid', 'getgid',
|
||||
'getgrgid', 'getgrnam', 'getgrouplist', 'getgroups', 'gethostbyaddr', 'gethostbyaddr_r', 'gethostbyname',
|
||||
'gethostbyname2', 'gethostbyname2_r', 'gethostbyname_r', 'gethostent', 'gethostname', 'getitimer', 'getline',
|
||||
'getlogin', 'getmntent', 'getmntent_r', 'getnameinfo', 'getnetbyaddr', 'getnetbyname', 'getopt', 'getopt_long',
|
||||
'getopt_long_only', 'getpagesize', 'getpeername', 'getpgid', 'getpgrp', 'getpid', 'getppid', 'getpriority',
|
||||
'getprogname', 'getprotobyname', 'getprotobynumber', 'getpt', 'getpwnam', 'getpwnam_r', 'getpwuid', 'getpwuid_r',
|
||||
'getresgid', 'getresuid', 'getrlimit', 'getrlimit64', 'getrusage', 'gets', 'getservbyname', 'getservbyport',
|
||||
'getservent', 'getsid', 'getsockname', 'getsockopt', 'gettid', 'gettimeofday', 'getuid', 'getutent', 'getwc',
|
||||
'getwchar', 'getxattr', 'gmtime', 'gmtime64', 'gmtime64_r', 'gmtime_r', 'grantpt', 'herror', 'hstrerror', 'htonl',
|
||||
'htons', 'if_indextoname', 'if_nametoindex', 'imaxabs', 'imaxdiv', 'inet_addr', 'inet_aton', 'inet_lnaof',
|
||||
'inet_makeaddr', 'inet_netof', 'inet_network', 'inet_nsap_addr', 'inet_nsap_ntoa', 'inet_ntoa', 'inet_ntop',
|
||||
'inet_pton', 'init_module', 'initgroups', 'initstate', 'inotify_add_watch', 'inotify_init', 'inotify_init1',
|
||||
'inotify_rm_watch', 'insque', 'ioctl', 'isalnum', 'isalnum_l', 'isalpha', 'isalpha_l', 'isascii', 'isatty',
|
||||
'isblank', 'isblank_l', 'iscntrl', 'iscntrl_l', 'isdigit', 'isdigit_l', 'isfinite', 'isfinitef', 'isfinitel',
|
||||
'isgraph', 'isgraph_l', 'isinf', 'isinff', 'isinfl', 'islower', 'islower_l', 'isnan', 'isnanf', 'isnanl',
|
||||
'isnormal', 'isnormalf', 'isnormall', 'isprint', 'isprint_l', 'ispunct', 'ispunct_l', 'isspace', 'isspace_l',
|
||||
'isupper', 'isupper_l', 'iswalnum', 'iswalnum_l', 'iswalpha', 'iswalpha_l', 'iswblank', 'iswblank_l', 'iswcntrl',
|
||||
'iswcntrl_l', 'iswctype', 'iswctype_l', 'iswdigit', 'iswdigit_l', 'iswgraph', 'iswgraph_l', 'iswlower',
|
||||
'iswlower_l', 'iswprint', 'iswprint_l', 'iswpunct', 'iswpunct_l', 'iswspace', 'iswspace_l', 'iswupper',
|
||||
'iswupper_l', 'iswxdigit', 'iswxdigit_l', 'isxdigit', 'isxdigit_l', 'jrand48', 'kill', 'killpg', 'klogctl', 'labs',
|
||||
'lchown', 'lcong48', 'ldexp', 'ldiv', 'lfind', 'lgetxattr', 'link', 'linkat', 'listen', 'listxattr', 'llabs',
|
||||
'lldiv', 'llistxattr', 'localeconv', 'localtime', 'localtime64', 'localtime64_r', 'localtime_r', 'login_tty',
|
||||
'longjmp', 'lrand48', 'lremovexattr', 'lsearch', 'lseek', 'lseek64', 'lsetxattr', 'lstat', 'lstat64', 'madvise',
|
||||
'mallinfo', 'malloc', 'malloc_info', 'malloc_usable_size', 'mbrlen', 'mbrtoc16', 'mbrtoc32', 'mbrtowc', 'mbsinit',
|
||||
'mbsnrtowcs', 'mbsrtowcs', 'mbstowcs', 'mbtowc', 'memalign', 'memccpy', 'memchr', 'memcmp', 'memcpy', 'memmem',
|
||||
'memmove', 'mempcpy', 'memrchr', 'memset', 'mincore', 'mkdir', 'mkdirat', 'mkdtemp', 'mkfifo', 'mkfifoat', 'mknod',
|
||||
'mknodat', 'mkostemp', 'mkostemp64', 'mkostemps', 'mkostemps64', 'mkstemp', 'mkstemp64', 'mkstemps', 'mkstemps64',
|
||||
'mktemp', 'mktime', 'mktime64', 'mlock', 'mlockall', 'mmap', 'mmap64', 'mount', 'mprotect', 'mrand48', 'mremap',
|
||||
'msync', 'munlock', 'munlockall', 'munmap', 'nanosleep', 'newlocale', 'nftw', 'nftw64', 'nice', 'nrand48',
|
||||
'ns_format_ttl', 'ns_get16', 'ns_get32', 'ns_initparse', 'ns_makecanon', 'ns_msg_getflag', 'ns_name_compress',
|
||||
'ns_name_ntol', 'ns_name_ntop', 'ns_name_pack', 'ns_name_pton', 'ns_name_rollback', 'ns_name_skip',
|
||||
'ns_name_uncompress', 'ns_name_unpack', 'ns_parserr', 'ns_put16', 'ns_put32', 'ns_samename', 'ns_skiprr',
|
||||
'ns_sprintrr', 'ns_sprintrrf', 'nsdispatch', 'ntohl', 'ntohs', 'open', 'open64', 'open_memstream',
|
||||
'open_wmemstream', 'openat', 'openat64', 'opendir', 'openlog', 'openpty', 'optarg', 'opterr', 'optind', 'optopt',
|
||||
'optreset', 'pathconf', 'pause', 'pclose', 'perror', 'personality', 'pipe', 'pipe2', 'poll', 'popen',
|
||||
'posix_fadvise', 'posix_fadvise64', 'posix_fallocate', 'posix_fallocate64', 'posix_madvise', 'posix_memalign',
|
||||
'posix_openpt', 'ppoll', 'prctl', 'pread', 'pread64', 'printf', 'prlimit', 'prlimit64', 'process_vm_readv',
|
||||
'process_vm_writev', 'pselect', 'psiginfo', 'psignal', 'pthread_atfork', 'pthread_attr_destroy',
|
||||
'pthread_attr_getdetachstate', 'pthread_attr_getguardsize', 'pthread_attr_getschedparam',
|
||||
'pthread_attr_getschedpolicy', 'pthread_attr_getscope', 'pthread_attr_getstack', 'pthread_attr_getstacksize',
|
||||
'pthread_attr_init', 'pthread_attr_setdetachstate', 'pthread_attr_setguardsize', 'pthread_attr_setschedparam',
|
||||
'pthread_attr_setschedpolicy', 'pthread_attr_setscope', 'pthread_attr_setstack', 'pthread_attr_setstacksize',
|
||||
'pthread_cond_broadcast', 'pthread_cond_destroy', 'pthread_cond_init', 'pthread_cond_signal',
|
||||
'pthread_cond_timedwait', 'pthread_cond_timedwait_monotonic', 'pthread_cond_timedwait_monotonic_np',
|
||||
'pthread_cond_timedwait_relative_np', 'pthread_cond_timeout_np', 'pthread_cond_wait', 'pthread_condattr_destroy',
|
||||
'pthread_condattr_getclock', 'pthread_condattr_getpshared', 'pthread_condattr_init', 'pthread_condattr_setclock',
|
||||
'pthread_condattr_setpshared', 'pthread_create', 'pthread_detach', 'pthread_equal', 'pthread_exit',
|
||||
'pthread_getattr_np', 'pthread_getcpuclockid', 'pthread_getschedparam', 'pthread_getspecific', 'pthread_gettid_np',
|
||||
'pthread_join', 'pthread_key_create', 'pthread_key_delete', 'pthread_kill', 'pthread_mutex_destroy',
|
||||
'pthread_mutex_init', 'pthread_mutex_lock', 'pthread_mutex_lock_timeout_np', 'pthread_mutex_timedlock',
|
||||
'pthread_mutex_trylock', 'pthread_mutex_unlock', 'pthread_mutexattr_destroy', 'pthread_mutexattr_getpshared',
|
||||
'pthread_mutexattr_gettype', 'pthread_mutexattr_init', 'pthread_mutexattr_setpshared', 'pthread_mutexattr_settype',
|
||||
'pthread_once', 'pthread_rwlock_destroy', 'pthread_rwlock_init', 'pthread_rwlock_rdlock',
|
||||
'pthread_rwlock_timedrdlock', 'pthread_rwlock_timedwrlock', 'pthread_rwlock_tryrdlock', 'pthread_rwlock_trywrlock',
|
||||
'pthread_rwlock_unlock', 'pthread_rwlock_wrlock', 'pthread_rwlockattr_destroy', 'pthread_rwlockattr_getkind_np',
|
||||
'pthread_rwlockattr_getpshared', 'pthread_rwlockattr_init', 'pthread_rwlockattr_setkind_np',
|
||||
'pthread_rwlockattr_setpshared', 'pthread_self', 'pthread_setname_np', 'pthread_setschedparam',
|
||||
'pthread_setspecific', 'pthread_sigmask', 'ptrace', 'ptsname', 'ptsname_r', 'putc', 'putc_unlocked', 'putchar',
|
||||
'putchar_unlocked', 'putenv', 'puts', 'pututline', 'putw', 'putwc', 'putwchar', 'pvalloc', 'pwrite', 'pwrite64',
|
||||
'qsort', 'quick_exit', 'raise', 'rand', 'rand_r', 'random', 'read', 'readahead', 'readdir', 'readdir64',
|
||||
'readdir64_r', 'readdir_r', 'readlink', 'readlinkat', 'readv', 'realloc', 'realpath', 'reboot', 'recv', 'recvfrom',
|
||||
'recvmmsg', 'recvmsg', 'regcomp', 'regerror', 'regexec', 'regfree', 'remove', 'removexattr', 'remque', 'rename',
|
||||
'renameat', 'res_init', 'res_mkquery', 'res_query', 'res_search', 'rewind', 'rewinddir', 'rmdir', 'sbrk', 'scandir',
|
||||
'scandir64', 'scanf', 'sched_get_priority_max', 'sched_get_priority_min', 'sched_getaffinity', 'sched_getcpu',
|
||||
'sched_getparam', 'sched_getscheduler', 'sched_rr_get_interval', 'sched_setaffinity', 'sched_setparam',
|
||||
'sched_setscheduler', 'sched_yield', 'seed48', 'seekdir', 'select', 'sem_close', 'sem_destroy', 'sem_getvalue',
|
||||
'sem_init', 'sem_open', 'sem_post', 'sem_timedwait', 'sem_trywait', 'sem_unlink', 'sem_wait', 'send', 'sendfile',
|
||||
'sendfile64', 'sendmmsg', 'sendmsg', 'sendto', 'setbuf', 'setbuffer', 'setegid', 'setenv', 'seteuid', 'setfsgid',
|
||||
'setfsuid', 'setgid', 'setgroups', 'sethostname', 'setitimer', 'setjmp', 'setlinebuf', 'setlocale', 'setlogmask',
|
||||
'setmntent', 'setns', 'setpgid', 'setpgrp', 'setpriority', 'setprogname', 'setregid', 'setresgid', 'setresuid',
|
||||
'setreuid', 'setrlimit', 'setrlimit64', 'setservent', 'setsid', 'setsockopt', 'setstate', 'settimeofday', 'setuid',
|
||||
'setutent', 'setvbuf', 'setxattr', 'shutdown', 'sigaction', 'sigaddset', 'sigaltstack', 'sigblock', 'sigdelset',
|
||||
'sigemptyset', 'sigfillset', 'siginterrupt', 'sigismember', 'siglongjmp', 'signal', 'signalfd', 'sigpending',
|
||||
'sigprocmask', 'sigqueue', 'sigsetjmp', 'sigsetmask', 'sigsuspend', 'sigtimedwait', 'sigwait', 'sigwaitinfo',
|
||||
'sleep', 'snprintf', 'socket', 'socketpair', 'splice', 'sprintf', 'srand', 'srand48', 'srandom', 'sscanf', 'stat',
|
||||
'stat64', 'statfs', 'statfs64', 'statvfs', 'statvfs64', 'stderr', 'stdin', 'stdout', 'stpcpy', 'stpncpy',
|
||||
'strcasecmp', 'strcasecmp_l', 'strcasestr', 'strcat', 'strchr', 'strcmp', 'strcoll', 'strcoll_l', 'strcpy',
|
||||
'strcspn', 'strdup', 'strerror', 'strerror_l', 'strerror_r', 'strftime', 'strftime_l', 'strlcat', 'strlcpy',
|
||||
'strlen', 'strncasecmp', 'strncasecmp_l', 'strncat', 'strncmp', 'strncpy', 'strndup', 'strnlen', 'strpbrk',
|
||||
'strptime', 'strrchr', 'strsep', 'strsignal', 'strspn', 'strstr', 'strtod', 'strtof', 'strtoimax', 'strtok',
|
||||
'strtok_r', 'strtol', 'strtold', 'strtold_l', 'strtoll', 'strtoll_l', 'strtoul', 'strtoull', 'strtoull_l',
|
||||
'strtoumax', 'strxfrm', 'strxfrm_l', 'swapoff', 'swapon', 'swprintf', 'swscanf', 'symlink', 'symlinkat', 'sync',
|
||||
'sys_siglist', 'sys_signame', 'syscall', 'sysconf', 'sysinfo', 'syslog', 'system', 'tcdrain', 'tcflow', 'tcflush',
|
||||
'tcgetattr', 'tcgetpgrp', 'tcgetsid', 'tcsendbreak', 'tcsetattr', 'tcsetpgrp', 'tdelete', 'tdestroy', 'tee',
|
||||
'telldir', 'tempnam', 'tfind', 'tgkill', 'time', 'timegm', 'timegm64', 'timelocal', 'timelocal64', 'timer_create',
|
||||
'timer_delete', 'timer_getoverrun', 'timer_gettime', 'timer_settime', 'timerfd_create', 'timerfd_gettime',
|
||||
'timerfd_settime', 'times', 'timezone', 'tmpfile', 'tmpnam', 'toascii', 'tolower', 'tolower_l', 'toupper',
|
||||
'toupper_l', 'towlower', 'towlower_l', 'towupper', 'towupper_l', 'truncate', 'truncate64', 'tsearch', 'ttyname',
|
||||
'ttyname_r', 'twalk', 'tzname', 'tzset', 'umask', 'umount', 'umount2', 'uname', 'ungetc', 'ungetwc', 'unlink',
|
||||
'unlinkat', 'unlockpt', 'unsetenv', 'unshare', 'uselocale', 'usleep', 'utime', 'utimensat', 'utimes', 'utmpname',
|
||||
'valloc', 'vasprintf', 'vdprintf', 'verr', 'verrx', 'vfdprintf', 'vfork', 'vfprintf', 'vfscanf', 'vfwprintf',
|
||||
'vfwscanf', 'vmsplice', 'vprintf', 'vscanf', 'vsnprintf', 'vsprintf', 'vsscanf', 'vswprintf', 'vswscanf', 'vsyslog',
|
||||
'vwarn', 'vwarnx', 'vwprintf', 'vwscanf', 'wait', 'wait4', 'waitid', 'waitpid', 'warn', 'warnx', 'wcpcpy',
|
||||
'wcpncpy', 'wcrtomb', 'wcscasecmp', 'wcscasecmp_l', 'wcscat', 'wcschr', 'wcscmp', 'wcscoll', 'wcscoll_l', 'wcscpy',
|
||||
'wcscspn', 'wcsdup', 'wcsftime', 'wcslcat', 'wcslcpy', 'wcslen', 'wcsncasecmp', 'wcsncasecmp_l', 'wcsncat',
|
||||
'wcsncmp', 'wcsncpy', 'wcsnlen', 'wcsnrtombs', 'wcspbrk', 'wcsrchr', 'wcsrtombs', 'wcsspn', 'wcsstr', 'wcstod',
|
||||
'wcstof', 'wcstoimax', 'wcstok', 'wcstol', 'wcstold', 'wcstold_l', 'wcstoll', 'wcstoll_l', 'wcstombs', 'wcstoul',
|
||||
'wcstoull', 'wcstoull_l', 'wcstoumax', 'wcswidth', 'wcsxfrm', 'wcsxfrm_l', 'wctob', 'wctomb', 'wctype', 'wctype_l',
|
||||
'wcwidth', 'wmemchr', 'wmemcmp', 'wmemcpy', 'wmemmove', 'wmempcpy', 'wmemset', 'wprintf', 'write', 'writev',
|
||||
'wscanf', 'main'
|
||||
)
|
||||
|
||||
# https://github.com/kaltura/kaltura-device-info-android
|
||||
DRM_PLAYER = {
|
||||
'name': 'Kaltura Device Info',
|
||||
'package': 'com.kaltura.kalturadeviceinfo',
|
||||
'url': 'https://github.com/kaltura/kaltura-device-info-android/releases/download/t3/kaltura-device-info-release.apk',
|
||||
'path': Path(__file__).parent.parent / 'docs' / 'server' / 'kaltura.apk'
|
||||
}
|
||||
|
||||
DRM_WEB = 'https://bitmovin.com/demos/drm'
|
||||
@@ -0,0 +1,371 @@
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
|
||||
from subprocess import run
|
||||
from typing import Optional, List, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
import requests
|
||||
|
||||
from frida.core import Device
|
||||
|
||||
# Suppress urllib3 warnings
|
||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR)
|
||||
|
||||
|
||||
def shell(prompt: List[str]) -> Tuple[bool, str]:
|
||||
"""
|
||||
Executes a shell command and returns its success status along with the output.
|
||||
|
||||
Args:
|
||||
prompt (List[str]): A list representing the command and its arguments to be executed.
|
||||
|
||||
Returns:
|
||||
Tuple[bool, str]: A tuple where the first value is True if the command failed,
|
||||
and the second value is the decoded standard output (stdout) string.
|
||||
|
||||
Example:
|
||||
shell(["ls", "-l"])
|
||||
(False, "total 0\n-rw-r--r-- 1 user ...")
|
||||
|
||||
Note:
|
||||
The return status is inverted (True means failure), which should be considered
|
||||
when checking execution outcomes.
|
||||
|
||||
"""
|
||||
# Convert all arguments to strings in case any are not
|
||||
prompt = list(map(str, prompt))
|
||||
|
||||
# Uncomment for debugging shell command execution
|
||||
# logging.getLogger('Shell').debug('Executing command: %s', ' '.join(prompt))
|
||||
try:
|
||||
# Execute the command and capture the output
|
||||
# TODO: Redirect standard output and error in PIP
|
||||
sp = run(prompt, capture_output=True)
|
||||
|
||||
# Return True if the command failed (non-zero exit), along with stdout
|
||||
return sp.returncode != 0, sp.stdout.decode('utf-8').strip()
|
||||
except KeyboardInterrupt:
|
||||
return False, ''
|
||||
|
||||
|
||||
class Remote:
|
||||
"""
|
||||
Handles ADB-based operations on Android devices using Frida.
|
||||
|
||||
This class provides a set of utilities for interacting with Android devices connected via ADB,
|
||||
including retrieving system properties, managing applications, enumerating processes, and executing shell commands.
|
||||
"""
|
||||
|
||||
def __init__(self, serial: Optional[str] = None, timeout: int = 5):
|
||||
"""
|
||||
Initializes and connects to an Android device using ADB and Frida.
|
||||
|
||||
Args:
|
||||
serial (Optional[str]): Specific device serial number. If not provided,
|
||||
the first connected USB device will be used.
|
||||
timeout (int): Timeout (in seconds) for connecting to the device.
|
||||
|
||||
Raises:
|
||||
EnvironmentError: If ADB is not found in the system PATH.
|
||||
Exception: If connection to the Android device fails.
|
||||
KeyError: If essential properties (e.g., SDK or ABI) cannot be retrieved.
|
||||
"""
|
||||
self.logger = logging.getLogger('Remote')
|
||||
|
||||
# Ensure ADB is installed and available in the system PATH
|
||||
if not shutil.which('adb'):
|
||||
raise EnvironmentError(
|
||||
'ADB is not recognized as an environment variable. '
|
||||
'Ensure ADB is installed and refer to the documentation: '
|
||||
'https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge'
|
||||
)
|
||||
|
||||
# Start the ADB server if it's not already running
|
||||
sp = shell(['adb', 'start-server'])
|
||||
if sp[0]:
|
||||
self.logger.warning('Unable to start ADB server (Error: %s)', sp[1])
|
||||
|
||||
# Attempt to connect to the device (or default to the first USB device)
|
||||
try:
|
||||
self.socket: Device = frida.get_device(serial, timeout) if serial else frida.get_usb_device(timeout)
|
||||
self.logger.info('Connected to device: %s (%s)', self.socket.name, self.socket.id)
|
||||
except Exception as e:
|
||||
self.logger.critical('Could not connect to any device: %s', e)
|
||||
raise e
|
||||
|
||||
# Construct the shell command prefix using the connected device ID
|
||||
self.__prefix = ['adb', '-s', self.socket.id, 'shell']
|
||||
|
||||
# Retrieve system properties (e.g., SDK version, CPU ABI)
|
||||
properties = self.enumerate_properties()
|
||||
|
||||
try:
|
||||
self.sdk = properties['ro.build.version.sdk']
|
||||
self.abi = properties['ro.product.cpu.abi']
|
||||
except Exception as e:
|
||||
raise KeyError('Failed to enumerate properties: %s', e)
|
||||
|
||||
self.logger.info('SDK API: %s', self.sdk)
|
||||
self.logger.info('ABI CPU: %s', self.abi)
|
||||
|
||||
def enumerate_properties(self) -> dict:
|
||||
"""
|
||||
Retrieves Android system properties from the connected device via ADB.
|
||||
|
||||
This method parses the output of the `getprop` shell command, casting values
|
||||
to appropriate Python types (e.g., integers or booleans) when possible. It is
|
||||
useful for gathering device configuration, build info, and environment metadata.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of system properties where keys are property names and
|
||||
values are their corresponding parsed values.
|
||||
"""
|
||||
# https://source.android.com/docs/core/architecture/configuration/add-system-properties?#shell-commands
|
||||
properties = {}
|
||||
|
||||
# Execute shell command to fetch all system properties using 'getprop'
|
||||
sp = shell([*self.__prefix, 'getprop', '|', 'strings'])
|
||||
if sp[0]:
|
||||
self.logger.error('Unable to retrieve system properties from the device (error: %s)', sp[1])
|
||||
return properties
|
||||
|
||||
# Parse each line of the output and extract key-value pairs
|
||||
for line in sp[1].splitlines():
|
||||
match = re.match(r'\[(.*?)]: \[(.*?)]', line)
|
||||
if match:
|
||||
key, value = match.groups()
|
||||
|
||||
# Try casting numeric strings to integers
|
||||
if value.isdigit():
|
||||
value = int(value)
|
||||
|
||||
# Try converting string booleans to Python boolean
|
||||
elif value.lower() in ('true', 'false'):
|
||||
value = value.lower() == 'true'
|
||||
|
||||
# Store the parsed key-value pair
|
||||
properties[key] = value
|
||||
|
||||
return properties
|
||||
|
||||
def enumerate_processes(self, pids: List[int] = None, names: List[str] = None) -> dict:
|
||||
"""
|
||||
Retrieves a list of running processes from the connected Android device.
|
||||
|
||||
This method uses ADB to run the `ps` command, processes the output, and filters
|
||||
the result by provided process IDs or names if specified. It gracefully handles
|
||||
variability in output format across Android versions.
|
||||
|
||||
Args:
|
||||
pids (List[int], optional): A list of process IDs to filter against. Only
|
||||
processes with these PIDs will be included in the result.
|
||||
names (List[str], optional): A list of process names to filter against.
|
||||
Only processes with these names will be included in the result.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping process IDs (int) to process names (str).
|
||||
|
||||
Exception:
|
||||
Logs and returns an empty dictionary if the `ps` command fails to execute.
|
||||
"""
|
||||
# https://github.com/frida/frida/issues/1225#issuecomment-604181822
|
||||
processes = {}
|
||||
|
||||
# Attempt to get the list of processes using the 'ps -A' command
|
||||
prompt = [*self.__prefix, 'ps']
|
||||
sp = shell([*prompt, '-A'])
|
||||
lines = sp[1].splitlines()
|
||||
|
||||
# Retry with simpler 'ps' if output seems too short (older Android or restricted shell)
|
||||
if len(lines) < 10:
|
||||
sp = shell(prompt)
|
||||
if sp[0]:
|
||||
self.logger.error('Failed to execute ps command (Error: %s)', sp[1])
|
||||
return processes
|
||||
lines = sp[1].splitlines()
|
||||
|
||||
# Iterate through process list, skipping the header
|
||||
for line in lines[1:]:
|
||||
try:
|
||||
parts = line.split() # USER,PID,PPID,VSZ,RSS,WCHAN,ADDR,S,NAME
|
||||
pid = int(parts[1]) # Extract the PID from the 2nd column
|
||||
name = ' '.join(parts[8:]).strip() # Extract the process name (column 9+)
|
||||
|
||||
# Handle cases where process name might be in brackets (e.g., kernel threads)
|
||||
name = name if name.startswith('[') else Path(name).name
|
||||
|
||||
# Apply optional filters
|
||||
if (pids and pid not in pids) or (names and name not in names):
|
||||
continue
|
||||
processes[pid] = name
|
||||
except Exception as e:
|
||||
# Suppress and skip lines that cannot be parsed correctly
|
||||
pass
|
||||
|
||||
return processes
|
||||
|
||||
def enumerate_applications(self, user: bool = True, system: bool = False) -> dict:
|
||||
"""
|
||||
Lists installed Android applications on the connected device.
|
||||
|
||||
This method uses ADB's package manager (pm) to list installed apps,
|
||||
with optional filtering for user-installed or system apps. It returns
|
||||
a mapping of package names to their corresponding APK file paths.
|
||||
|
||||
Args:
|
||||
user (bool): If True, include user-installed applications (default: True).
|
||||
system (bool): If True, include system applications (default: False).
|
||||
|
||||
Returns:
|
||||
dict: A dictionary where the keys are package names (str) and the values
|
||||
are file paths (str) to their APKs.
|
||||
"""
|
||||
applications = {}
|
||||
|
||||
# Validate input to ensure at least one filter is active
|
||||
if not user and not system:
|
||||
return applications # Nothing to return if both are disabled
|
||||
|
||||
# Construct the command to retrieve package info
|
||||
prompt = [*self.__prefix, 'pm', 'list', 'packages', '-f']
|
||||
if user and not system:
|
||||
prompt.append('-3') # Filter only user-installed apps
|
||||
elif not user and system:
|
||||
prompt.append('-s') # Filter only system apps
|
||||
|
||||
# Run the shell command to get the list of packages
|
||||
sp = shell(prompt)
|
||||
if sp[0]:
|
||||
self.logger.error('Unable to list installed apps (Error: %s)', sp[0])
|
||||
return applications
|
||||
|
||||
# Parse command output line-by-line
|
||||
for line in sp[1].splitlines():
|
||||
try:
|
||||
# Example line format: package:/data/app/com.example.app-1/base.apk=com.example.app
|
||||
path, package = line.strip().split(':', 1)[1].rsplit('=', 1)
|
||||
applications[package] = path
|
||||
except Exception as e:
|
||||
# Skip lines that do not conform to expected structure
|
||||
pass
|
||||
|
||||
return applications
|
||||
|
||||
def open_url(self, url: str) -> bool:
|
||||
"""
|
||||
Launches the specified URL on the connected Android device using the default browser.
|
||||
|
||||
This uses Android's Activity Manager (am) to start an intent with the action
|
||||
`android.intent.action.VIEW`, which is the standard way to launch URLs.
|
||||
|
||||
Args:
|
||||
url (str): The web address to open on the device (e.g., "https://example.com").
|
||||
|
||||
Returns:
|
||||
bool: True if the command executed successfully and the intent was launched;
|
||||
False if the shell command failed or was rejected by the system.
|
||||
"""
|
||||
# Execute the shell command to open the URL using the Android 'am' (Activity Manager) command.
|
||||
sp = shell([*self.__prefix, 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url])
|
||||
if sp[0]:
|
||||
self.logger.error('Failed to open URL: %s (Error: %s)', url, sp[1])
|
||||
|
||||
# Return True if command succeeded (sp[0] is 0), False otherwise
|
||||
return not sp[0]
|
||||
|
||||
def install_application(self, path: Optional[Path] = None, url: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Installs an APK on the connected Android device from either a local file or a remote URL.
|
||||
|
||||
Args:
|
||||
path (Optional[Path]): Path to the APK file stored locally.
|
||||
url (Optional[str]): Direct URL to download the APK from.
|
||||
|
||||
Returns:
|
||||
bool: True if the application was successfully installed, False otherwise.
|
||||
"""
|
||||
# Prepare the shell command for installation
|
||||
prompt = [*self.__prefix[:-1], 'install']
|
||||
|
||||
# If a valid local path is provided, attempt to install the APK from that file
|
||||
if path and path.is_file():
|
||||
sp = shell([*prompt, path]) # Run the installation command with the local file path
|
||||
if not sp[0]:
|
||||
return True # Installation succeeded
|
||||
self.logger.error('Could not install the APK from local path: %s (Error: %s)', path, sp[1])
|
||||
|
||||
# If a URL is provided, try to download the APK and install it temporarily
|
||||
status = False
|
||||
if url:
|
||||
file = Path('tmp.apk') # Temporary file to store the downloaded APK
|
||||
try:
|
||||
# Send a GET request to download the APK
|
||||
r = requests.request(
|
||||
method='GET',
|
||||
url=url,
|
||||
headers={
|
||||
'Accept': '*/*',
|
||||
'User-Agent': 'KeyDive/ADB'
|
||||
}
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# Write the downloaded content to a temporary APK file
|
||||
file.write_bytes(r.content)
|
||||
|
||||
# Attempt to install the downloaded APK
|
||||
status = self.install_application(path=file)
|
||||
except Exception as e:
|
||||
self.logger.error('Failed to download or install APK from URL: %s (Error: %s)', url, e)
|
||||
finally:
|
||||
# Clean up the temporary file regardless of success or failure
|
||||
file.unlink(missing_ok=True)
|
||||
|
||||
return status
|
||||
|
||||
def start_application(self, package: str) -> bool:
|
||||
"""
|
||||
Attempts to start an Android application using its package name by locating
|
||||
and invoking its main activity via the Activity Manager.
|
||||
|
||||
Args:
|
||||
package (str): The package name of the application to start.
|
||||
|
||||
Returns:
|
||||
bool: True if the application was successfully launched, False otherwise.
|
||||
"""
|
||||
# Get package information using dumpsys
|
||||
sp = shell([*self.__prefix, 'dumpsys', 'package', package])
|
||||
if sp[0]:
|
||||
self.logger.error('Unable to retrieve package information (Error: %s)', sp[1])
|
||||
return False
|
||||
|
||||
# Clean up output and filter out empty lines for consistency
|
||||
lines = sp[1].splitlines()
|
||||
lines = [l.strip() for l in lines if l.strip()]
|
||||
|
||||
# Scan for the MAIN intent line and extract the associated activity
|
||||
for i, line in enumerate(lines):
|
||||
if 'android.intent.action.MAIN' in line:
|
||||
match = re.search(fr'({package}/[^ ]+)', lines[i + 1])
|
||||
if match:
|
||||
# Format: com.example.package/.MainActivity
|
||||
main_activity = match.group()
|
||||
|
||||
# Attempt to start the application using the resolved activity
|
||||
sp = shell([*self.__prefix, 'am', 'start', '-n', main_activity])
|
||||
if not sp[0]:
|
||||
return True
|
||||
|
||||
self.logger.error('Failed to start app %s (Error: %s)', package, sp[1])
|
||||
break
|
||||
|
||||
# TODO: adb shell monkey -p com.topjohnwu.magisk -c android.intent.category.LAUNCHER 1
|
||||
self.logger.error('No MAIN activity found for package "%s" or package not installed.', package)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ('Remote',)
|
||||
@@ -0,0 +1,114 @@
|
||||
import re
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
|
||||
class Vendor:
|
||||
"""
|
||||
Represents a vendor-specific configuration including process name, minimum SDK version,
|
||||
OEM versioning information, and the name of a related native library.
|
||||
|
||||
This class is used to identify vendor-specific behaviors on Android devices and provides
|
||||
regex-based helpers for pattern matching against processes and libraries.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
process: str,
|
||||
min_sdk: int,
|
||||
min_oem: Tuple[int, str],
|
||||
library: str
|
||||
):
|
||||
"""
|
||||
Initializes a Vendor instance with identifying details.
|
||||
|
||||
Args:
|
||||
process (str): The name of the process to identify for the vendor (can be pattern-based).
|
||||
min_sdk (int): The minimum required Android SDK version for compatibility.
|
||||
min_oem (Tuple[int, str]): Tuple defining the minimum OEM version, e.g., (version_code, version_name).
|
||||
library (str): The shared library name or pattern used by the vendor.
|
||||
|
||||
"""
|
||||
self.process = process
|
||||
self.min_sdk = min_sdk
|
||||
self.min_oem = min_oem
|
||||
self.library = library
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Generates a string representation of the current instance for debugging purposes.
|
||||
|
||||
This method is primarily used to help developers understand the state of the object
|
||||
during logging or interactive debugging by displaying all attribute names and values
|
||||
in a clean, readable format.
|
||||
|
||||
Returns:
|
||||
str: A string showing the class name and its instance variables with their values.
|
||||
"""
|
||||
# Construct a formatted string with the class name and a list of key=value pairs from the instance's attributes
|
||||
return '{name}({items})'.format(
|
||||
name=self.__class__.__name__,
|
||||
items=', '.join([f'{k}={repr(v)}' for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def __pattern(value: str) -> str:
|
||||
"""
|
||||
Converts a given string into a regex-compatible pattern that allows for matching
|
||||
flexible naming conventions found in Android service or library identifiers.
|
||||
|
||||
This is particularly useful when dealing with variant suffixes (e.g., `-lazy`, version tags),
|
||||
or optional annotations (e.g., `@<version>` in `.so` or `.widevine` modules).
|
||||
|
||||
Args:
|
||||
value (str): The base string (e.g., filename or service name) to convert into a regex pattern.
|
||||
|
||||
Returns:
|
||||
str: A regex pattern that can be used to match common variants of the input string.
|
||||
"""
|
||||
return (value
|
||||
.replace('-service', r'-service(?:-lazy)?')
|
||||
.replace('.widevine', r'.widevine(?:@\S+)?')
|
||||
.replace('.so', r'(?:@\S+)?.so')
|
||||
.replace('.', r'\.'))
|
||||
|
||||
def is_process(self, value: str) -> bool:
|
||||
"""
|
||||
Determines whether the provided process name matches the expected vendor-specific process pattern.
|
||||
|
||||
This is useful when trying to detect or hook into a known target process,
|
||||
especially when its name can include runtime-specific suffixes (e.g., versioned .so files or services).
|
||||
|
||||
Args:
|
||||
value (str): The name of the process to evaluate.
|
||||
|
||||
Returns:
|
||||
bool: True if the process name matches the generated pattern; otherwise, False.
|
||||
"""
|
||||
# Generate a regex pattern from the expected process name
|
||||
pattern = self.__pattern(self.process)
|
||||
|
||||
# Check if the input value matches the regex pattern
|
||||
return bool(re.match(pattern, value))
|
||||
|
||||
def get_library(self, values: List[dict]) -> Optional[dict]:
|
||||
"""
|
||||
Retrieves the first matching library dictionary from a provided list based on a dynamically generated regex pattern.
|
||||
|
||||
This method is useful when trying to identify a target library in a list of shared objects (e.g., `.so` files),
|
||||
especially when the exact name may include version tags or runtime decorations (e.g., `libxyz@1.2.so`).
|
||||
|
||||
Args:
|
||||
values (List[dict]): A list of dictionaries, each expected to contain a 'name' key with the library's filename.
|
||||
|
||||
Returns:
|
||||
Optional[dict]: The first dictionary whose 'name' matches the expected regex pattern; returns None if no match is found.
|
||||
"""
|
||||
# Create a regex pattern based on the expected library name, allowing flexibility for suffixes or variants
|
||||
pattern = self.__pattern(self.library)
|
||||
|
||||
# Iterate through the list and return the first dictionary with a matching 'name'
|
||||
return next((v for v in values if re.match(pattern, v['name'])), None)
|
||||
|
||||
|
||||
__all__ = ('Vendor',)
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from typing import Union
|
||||
from zlib import crc32
|
||||
from unidecode import unidecode
|
||||
from pathlib import Path
|
||||
|
||||
from pathvalidate import sanitize_filepath, sanitize_filename
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from Cryptodome.PublicKey.RSA import RsaKey
|
||||
from pywidevine.device import Device, DeviceTypes
|
||||
from pywidevine.license_protocol_pb2 import (
|
||||
SignedMessage, LicenseRequest, ClientIdentification, SignedDrmCertificate, DrmCertificate,
|
||||
EncryptedClientIdentification)
|
||||
|
||||
from keydive.constants import OEM_CRYPTO_API
|
||||
from keydive.keybox import Keybox
|
||||
|
||||
|
||||
class Cdm:
|
||||
"""
|
||||
The Cdm class manages CDM-related operations, such as setting challenge data,
|
||||
extracting and storing private keys, and exporting device information.
|
||||
"""
|
||||
|
||||
def __init__(self, keybox: bool = False):
|
||||
"""
|
||||
Initializes the Cdm object, setting up a logger and containers for client IDs and private keys.
|
||||
|
||||
Parameters:
|
||||
keybox (bool, optional): Initializes a Keybox instance for secure key management.
|
||||
"""
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
# https://github.com/devine-dl/pywidevine
|
||||
self.client_id: dict[int, ClientIdentification] = {}
|
||||
self.private_key: dict[int, RsaKey] = {}
|
||||
|
||||
# Optionally initialize a Keybox instance for secure key management if 'keybox' is True
|
||||
self.keybox = Keybox() if keybox else None
|
||||
|
||||
@staticmethod
|
||||
def __client_info(client_id: ClientIdentification) -> dict:
|
||||
"""
|
||||
Converts client identification information to a dictionary.
|
||||
|
||||
Parameters:
|
||||
client_id (ClientIdentification): The client identification.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of client information.
|
||||
"""
|
||||
return {e.name: e.value for e in client_id.client_info}
|
||||
|
||||
@staticmethod
|
||||
def __encrypted_client_info(encrypted_client_id: EncryptedClientIdentification) -> dict:
|
||||
"""
|
||||
Converts encrypted client identification information to a dictionary.
|
||||
|
||||
Parameters:
|
||||
encrypted_client_id (EncryptedClientIdentification): The encrypted client identification.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of encrypted client information.
|
||||
"""
|
||||
content = {
|
||||
"providerId": encrypted_client_id.provider_id,
|
||||
"serviceCertificateSerialNumber": encrypted_client_id.service_certificate_serial_number,
|
||||
"encryptedClientId": encrypted_client_id.encrypted_client_id,
|
||||
"encryptedClientIdIv": encrypted_client_id.encrypted_client_id_iv,
|
||||
"encryptedPrivacyKey": encrypted_client_id.encrypted_privacy_key
|
||||
}
|
||||
return {
|
||||
k: base64.b64encode(v).decode("utf-8") if isinstance(v, bytes) else v
|
||||
for k, v in content.items()
|
||||
}
|
||||
|
||||
def set_challenge(self, data: Union[Path, bytes]) -> None:
|
||||
"""
|
||||
Sets the challenge data by extracting device information and client ID.
|
||||
|
||||
Parameters:
|
||||
data (Union[Path, bytes]): Challenge data as a file path or raw bytes.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file path doesn't exist.
|
||||
Exception: Logs any other exceptions that occur.
|
||||
"""
|
||||
try:
|
||||
# Check if the data is a Path object, indicating it's a file path
|
||||
if isinstance(data, Path):
|
||||
data = data.read_bytes()
|
||||
|
||||
# Parse the signed message from the data
|
||||
signed_message = SignedMessage()
|
||||
signed_message.ParseFromString(data)
|
||||
|
||||
# Parse the license request from the signed message
|
||||
license_request = LicenseRequest()
|
||||
license_request.ParseFromString(signed_message.msg)
|
||||
|
||||
# Extract the encrypted client ID, if available
|
||||
# https://integration.widevine.com/diagnostics
|
||||
encrypted_client_id: EncryptedClientIdentification = license_request.encrypted_client_id
|
||||
if encrypted_client_id.SerializeToString():
|
||||
# If encrypted, log the encrypted client ID and indicate encryption
|
||||
self.logger.info("Receive encrypted client id: \n\n%s\n", json.dumps(self.__encrypted_client_info(encrypted_client_id), indent=2))
|
||||
self.logger.warning("The client ID of the challenge is encrypted")
|
||||
else:
|
||||
# If unencrypted, extract and set the client ID
|
||||
client_id: ClientIdentification = license_request.client_id
|
||||
self.set_client_id(data=client_id)
|
||||
|
||||
except FileNotFoundError as e:
|
||||
raise FileNotFoundError(f"Challenge file not found: {data}") from e
|
||||
except Exception as e:
|
||||
self.logger.debug("Failed to set challenge data: %s", e)
|
||||
|
||||
def set_private_key(self, data: Union[Path, bytes], name: str = None) -> None:
|
||||
"""
|
||||
Sets the private key from the provided data.
|
||||
|
||||
Parameters:
|
||||
data (Union[Path, bytes]): The private key data, either as a file path or byte data.
|
||||
name (str, optional): Function name for verification against known functions.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the file path doesn't exist.
|
||||
Exception: Logs any other exceptions that occur.
|
||||
"""
|
||||
try:
|
||||
# Check if the data is a Path object, indicating it's a file path
|
||||
if isinstance(data, Path):
|
||||
data = data.read_bytes()
|
||||
|
||||
# Import the private key using the RSA module
|
||||
key = RSA.import_key(data)
|
||||
|
||||
# Log the private key if it's not already in the dictionary
|
||||
if key.n not in self.private_key:
|
||||
self.logger.info("Receive private key: \n\n%s\n", key.exportKey("PEM").decode("utf-8"))
|
||||
|
||||
# If a function name is provided, verify it against known functions
|
||||
if name and name not in OEM_CRYPTO_API:
|
||||
self.logger.warning("The function '%s' does not belong to the referenced functions. Communicate it to the developer to improve the tool.",name)
|
||||
|
||||
# Store the private key in the dictionary, using the modulus (key.n) as the key
|
||||
self.private_key[key.n] = key
|
||||
except FileNotFoundError as e:
|
||||
raise FileNotFoundError(f"Private key file not found: {data}") from e
|
||||
except Exception as e:
|
||||
self.logger.debug("Failed to set private key: %s", e)
|
||||
|
||||
def set_client_id(self, data: Union[ClientIdentification, bytes]) -> None:
|
||||
"""
|
||||
Sets the client ID from the provided data.
|
||||
|
||||
Parameters:
|
||||
data (Union[ClientIdentification, bytes]): The client ID data.
|
||||
"""
|
||||
try:
|
||||
# Check if the provided data is already a `ClientIdentification` object
|
||||
if isinstance(data, ClientIdentification):
|
||||
client_id = data
|
||||
else:
|
||||
# Deserialize the byte data into a `ClientIdentification` object
|
||||
client_id = ClientIdentification()
|
||||
client_id.ParseFromString(data)
|
||||
|
||||
# Initialize objects for parsing the DRM certificate and signed certificate
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
drm_certificate = DrmCertificate()
|
||||
|
||||
# Parse the signed DRM certificate from the client ID token
|
||||
signed_drm_certificate.ParseFromString(client_id.token)
|
||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||
|
||||
# Extract the public key from the DRM certificate
|
||||
public_key = drm_certificate.public_key
|
||||
key = RSA.importKey(public_key)
|
||||
|
||||
# Check if this public key has already been recorded and log the client ID info
|
||||
if key.n not in self.client_id:
|
||||
self.logger.info("Receive client id: \n\n%s\n", json.dumps(self.__client_info(client_id), indent=2))
|
||||
|
||||
# Store the client ID in the client_id dictionary, using the public key modulus (`key.n`) as the key
|
||||
self.client_id[key.n] = client_id
|
||||
except Exception as e:
|
||||
self.logger.debug("Failed to set client ID: %s", e)
|
||||
|
||||
def set_device_id(self, data: bytes) -> None:
|
||||
"""
|
||||
Sets the device ID in the keybox.
|
||||
|
||||
Parameters:
|
||||
data (bytes): The device ID to be stored in the keybox.
|
||||
"""
|
||||
if self.keybox:
|
||||
self.keybox.set_device_id(data=data)
|
||||
|
||||
def set_keybox(self, data: bytes) -> None:
|
||||
"""
|
||||
Sets the keybox data.
|
||||
|
||||
Parameters:
|
||||
data (bytes): The keybox data to be set.
|
||||
"""
|
||||
if self.keybox:
|
||||
self.keybox.set_keybox(data=data)
|
||||
|
||||
def export(self, parent: Path, wvd: bool = False) -> bool:
|
||||
"""
|
||||
Exports client ID, private key, and optionally WVD files to disk.
|
||||
|
||||
Parameters:
|
||||
parent (Path): Directory to export the files to.
|
||||
wvd (bool, optional): Whether to export WVD files. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bool: True if any keys were exported, otherwise False.
|
||||
"""
|
||||
# Find the intersection of client IDs and private keys
|
||||
keys = self.client_id.keys() & self.private_key.keys()
|
||||
|
||||
for k in keys:
|
||||
# Retrieve client information based on the client ID
|
||||
client_info = self.__client_info(self.client_id[k])
|
||||
|
||||
# https://github.com/devine-dl/pywidevine/blob/master/pywidevine/main.py#L211
|
||||
device = Device(
|
||||
client_id=self.client_id[k].SerializeToString(),
|
||||
private_key=self.private_key[k].exportKey("PEM"),
|
||||
type_=DeviceTypes.ANDROID,
|
||||
security_level=3,
|
||||
flags=None
|
||||
)
|
||||
|
||||
# Generate a sanitized file path for exporting the data
|
||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146958022
|
||||
parent = sanitize_filepath(parent / client_info["company_name"] / client_info["model_name"] / str(device.system_id) / str(k)[:10])
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Export the client ID to a binary file
|
||||
path_id_bin = parent / "client_id.bin"
|
||||
path_id_bin.write_bytes(data=device.client_id.SerializeToString())
|
||||
self.logger.info("Exported client ID: %s", path_id_bin)
|
||||
|
||||
# Export the private key to a PEM file
|
||||
path_key_bin = parent / "private_key.pem"
|
||||
path_key_bin.write_bytes(data=device.private_key.exportKey("PEM"))
|
||||
self.logger.info("Exported private key: %s", path_key_bin)
|
||||
|
||||
# If the WVD option is enabled, export the WVD file
|
||||
if wvd:
|
||||
# Serialize the device to WVD format
|
||||
wvd_bin = device.dumps()
|
||||
|
||||
# Generate a unique name for the WVD file using client and device details
|
||||
name = f"{client_info['company_name']} {client_info['model_name']}"
|
||||
if client_info.get("widevine_cdm_version"):
|
||||
name += f" {client_info['widevine_cdm_version']}"
|
||||
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
|
||||
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||
path_wvd = parent / sanitize_filename(f"{name}_{device.system_id}_l{device.security_level}.wvd")
|
||||
|
||||
# Export the WVD file to disk
|
||||
path_wvd.write_bytes(data=wvd_bin)
|
||||
self.logger.info("Exported WVD: %s", path_wvd)
|
||||
|
||||
# If keybox is available and hasn't been exported, issue a warning
|
||||
if self.keybox and not self.keybox.export(parent=parent.parent):
|
||||
self.logger.warning("The keybox has not been intercepted or decrypted")
|
||||
|
||||
# Return True if any keys were exported, otherwise return False
|
||||
return len(keys) > 0
|
||||
|
||||
|
||||
__all__ = ("Cdm",)
|
||||
@@ -1,145 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
from keydive.vendor import Vendor
|
||||
|
||||
# https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:bionic/libc/libc.map.txt
|
||||
NATIVE_C_API = {
|
||||
# BUILT-IN
|
||||
"main",
|
||||
# STDIO
|
||||
"fclose", "fflush", "fgetc", "fgetpos", "fgets", "fopen", "fprintf", "fputc", "fputs", "fread", "freopen",
|
||||
"fscanf", "fseek", "fsetpos", "ftell", "fwrite", "getc", "getchar", "gets", "perror", "printf", "putc",
|
||||
"putchar", "puts", "remove", "rename", "rewind", "scanf", "setbuf", "setvbuf", "sprintf", "sscanf", "tmpfile",
|
||||
"tmpnam", "ungetc", "vfprintf", "vprintf", "vsprintf", "fileno", "feof", "ferror", "snprintf",
|
||||
# STDLIB
|
||||
"abort", "abs", "atexit", "atof", "atoi", "atol", "bsearch", "calloc", "div", "exit", "free", "getenv", "labs",
|
||||
"ldiv", "malloc", "mblen", "mbstowcs", "mbtowc", "qsort", "rand", "realloc", "srand", "strtod", "strtol",
|
||||
"strtoul", "system", "wcstombs", "wctomb",
|
||||
# STRING
|
||||
"memchr", "memcmp", "memcpy", "memmove", "memset", "strcat", "strchr", "strcmp", "strcoll", "strcpy", "strcspn",
|
||||
"strerror", "strlen", "strncat", "strncmp", "strncpy", "strpbrk", "strrchr", "strspn", "strstr", "strtok",
|
||||
"strxfrm", "strncasecmp",
|
||||
# MATH
|
||||
"acos", "asin", "atan", "atan2", "cos", "cosh", "exp", "fabs", "floor", "fmod", "frexp", "ldexp", "log",
|
||||
"log10", "modf", "pow", "sin", "sinh", "sqrt", "tan", "tanh",
|
||||
# CTYPE
|
||||
"isalnum", "isalpha", "iscntrl", "isdigit", "isgraph", "islower", "isprint", "ispunct", "isspace", "isupper",
|
||||
"isxdigit", "tolower", "toupper",
|
||||
# TIME
|
||||
"asctime", "clock", "ctime", "difftime", "gmtime", "localtime", "mktime", "strftime", "time",
|
||||
# UNISTD
|
||||
"access", "alarm", "chdir", "chown", "close", "dup", "dup2", "execle", "execv", "execve", "execvp", "fork",
|
||||
"fpathconf", "getcwd", "getegid", "geteuid", "getgid", "getgroups", "getlogin", "getopt", "getpgid", "getpgrp",
|
||||
"getpid", "getppid", "getuid", "isatty", "lseek", "pathconf", "pause", "pipe", "read", "rmdir", "setgid",
|
||||
"setpgid", "setsid", "setuid", "sleep", "sysconf", "tcgetpgrp", "tcsetpgrp", "ttyname", "ttyname_r", "write",
|
||||
"fsync", "unlink", "syscall", "getpagesize",
|
||||
# FCNTL
|
||||
"creat", "fcntl", "open",
|
||||
# SYS_TYPE
|
||||
"fd_set", "FD_CLR", "FD_ISSET", "FD_SET", "FD_ZERO",
|
||||
# SYS_STAT
|
||||
"chmod", "fchmod", "fstat", "mkdir", "mkfifo", "stat", "umask",
|
||||
# SYS_TIME
|
||||
"gettimeofday", "select", "settimeofday",
|
||||
# SIGNAL
|
||||
"signal", "raise", "kill", "sigaction", "sigaddset", "sigdelset", "sigemptyset", "sigfillset", "sigismember",
|
||||
"sigpending", "sigprocmask", "sigsuspend", "alarm", "pause",
|
||||
# SETJMP
|
||||
"longjmp", "setjmp",
|
||||
# ERRNO
|
||||
"errno", "strerror", "perror",
|
||||
# ASSERT
|
||||
"assert",
|
||||
# LOCAL
|
||||
"localeconv", "setlocale",
|
||||
# WCHAR
|
||||
"btowc", "fgetwc", "fgetws", "fputwc", "fputws", "fwide", "fwprintf", "fwscanf", "getwc", "getwchar", "mbrlen",
|
||||
"mbrtowc", "mbsinit", "mbsrtowcs", "putwc", "putwchar", "swprintf", "swscanf", "ungetwc", "vfwprintf",
|
||||
"vfwscanf", "vwprintf", "vwscanf", "wcrtomb", "wcscat", "wcschr", "wcscmp", "wcscoll", "wcscpy", "wcscspn",
|
||||
"wcsftime", "wcslen", "wcsncat", "wcsncmp", "wcsncpy", "wcspbrk", "wcsrchr", "wcsrtombs", "wcsspn", "wcsstr",
|
||||
"wcstod", "wcstok", "wcstol", "wcstombs", "wcstoul", "wcsxfrm", "wctob", "wmemchr", "wmemcmp", "wmemcpy",
|
||||
"wmemmove", "wmemset", "wprintf", "wscanf",
|
||||
# WCTYPE
|
||||
"iswalnum", "iswalpha", "iswcntrl", "iswdigit", "iswgraph", "iswlower", "iswprint", "iswpunct", "iswspace",
|
||||
"iswupper", "iswxdigit", "towlower", "towupper", "iswctype", "wctype",
|
||||
# STDDEF
|
||||
"NULL", "offsetof", "ptrdiff_t", "size_t", "wchar_t",
|
||||
# STDARG
|
||||
"va_arg", "va_end", "va_start",
|
||||
# DLFCN
|
||||
"dlclose", "dlerror", "dlopen", "dlsym",
|
||||
# DIRENT
|
||||
"closedir", "opendir", "readdir",
|
||||
# SYS_SENDFILE
|
||||
"sendfile",
|
||||
# SYS_MMAN
|
||||
"mmap", "mprotect", "munmap",
|
||||
# SYS_UTSNAME
|
||||
"uname",
|
||||
# LINK
|
||||
"dladdr"
|
||||
}
|
||||
|
||||
# https://cs.android.com/search?q=oemcrypto&sq=&ss=android%2Fplatform%2Fsuperproject
|
||||
OEM_CRYPTO_API = {
|
||||
# Mapping of function names across different API levels (obfuscated names may vary).
|
||||
"rnmsglvj", "polorucp", "kqzqahjq", "pldrclfq", "kgaitijd", "cwkfcplc", "crhqcdet", "ulns", "dnvffnze", "ygjiljer",
|
||||
"qbjxtubz", "qkfrcjtw", "rbhjspoh", "zgtjmxko", "igrqajte", "ofskesua", "qllcoacg", "pukctkiv", "ehdqmfmd",
|
||||
"xftzvkwx", "gndskkuk", "wcggmnnx", "kaatohcz", "ktmgdchz", "jkcwonus", "ehmduqyt", "vewtuecx", "mxrbzntq",
|
||||
"isyowgmp", "flzfkhbc", "rtgejgqb", "sxxprljw", "ebxjbtxl", "pcmtpkrj", "ncmqbmbc"
|
||||
# Add more as needed for different versions.
|
||||
}
|
||||
|
||||
# https://developer.android.com/tools/releases/platforms
|
||||
CDM_VENDOR_API = {
|
||||
"mediaserver": [
|
||||
Vendor(22, 11, "1.0", r"libwvdrmengine(?:@\S+)?\.so")
|
||||
],
|
||||
"mediadrmserver": [
|
||||
Vendor(24, 11, "1.0", r"libwvdrmengine(?:@\S+)?\.so")
|
||||
],
|
||||
"android.hardware.drm@1.0-service.widevine": [
|
||||
Vendor(26, 13, "5.1.0", r"libwvhidl(?:@\S+)?\.so")
|
||||
],
|
||||
"android.hardware.drm@1.1-service.widevine": [
|
||||
Vendor(28, 14, "14.0.0", r"libwvhidl(?:@\S+)?\.so")
|
||||
],
|
||||
"android.hardware.drm@1.2-service.widevine": [
|
||||
Vendor(29, 15, "15.0.0", r"libwvhidl(?:@\S+)?\.so")
|
||||
],
|
||||
"android.hardware.drm@1.3-service.widevine": [
|
||||
Vendor(30, 16, "16.0.0", r"libwvhidl(?:@\S+)?\.so")
|
||||
],
|
||||
"android.hardware.drm@1.4-service.widevine": [
|
||||
Vendor(31, 16, "16.1.0", r"libwvhidl(?:@\S+)?\.so")
|
||||
],
|
||||
"android.hardware.drm-service.widevine": [
|
||||
Vendor(33, 17, "17.0.0", r"libwvaidl(?:@\S+)?\.so"),
|
||||
Vendor(34, 18, "18.0.0", r"android\.hardware\.drm-service(?:-lazy)?\.widevine(?:@\S+)?"),
|
||||
Vendor(35, 18, "19.0.1", r"android\.hardware\.drm-service(?:-lazy)?\.widevine(?:@\S+)?")
|
||||
]
|
||||
}
|
||||
|
||||
# https://developers.google.com/widevine
|
||||
CDM_FUNCTION_API = {
|
||||
"UsePrivacyMode",
|
||||
"GetCdmClientPropertySet",
|
||||
"PrepareKeyRequest",
|
||||
"getOemcryptoDeviceId",
|
||||
"lcc07",
|
||||
"oecc07",
|
||||
"Read",
|
||||
"x1c36",
|
||||
"runningcrc"
|
||||
}
|
||||
|
||||
# Maximum clear API level for Keybox
|
||||
# https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:trusty/user/app/sample/hwcrypto/keybox/keybox.c
|
||||
KEYBOX_MAX_CLEAR_API = 28
|
||||
|
||||
# https://github.com/kaltura/kaltura-device-info-android
|
||||
DRM_PLAYER = {
|
||||
"package": "com.kaltura.kalturadeviceinfo",
|
||||
"path": Path(__file__).parent.parent / "docs" / "server" / "kaltura.apk",
|
||||
"url": "https://github.com/kaltura/kaltura-device-info-android/releases/download/t3/kaltura-device-info-release.apk"
|
||||
}
|
||||
+395
-157
@@ -1,228 +1,466 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from typing import Optional, Tuple, Literal
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
import xmltodict
|
||||
|
||||
from frida import ServerNotRunningError
|
||||
from frida.core import Session, Script
|
||||
from pathvalidate import sanitize_filepath
|
||||
|
||||
from keydive.adb import ADB
|
||||
from keydive.cdm import Cdm
|
||||
from keydive.constants import OEM_CRYPTO_API, NATIVE_C_API, CDM_FUNCTION_API
|
||||
from keydive.vendor import Vendor
|
||||
from keydive.adb import DRM_PLAYER, DRM_WEB, NATIVE_C_API
|
||||
from keydive.adb.remote import Remote
|
||||
from keydive.adb.vendor import Vendor
|
||||
from keydive.drm import CDM_VENDOR_API, OEM_CRYPTO_API
|
||||
from keydive.drm.cdm import Cdm
|
||||
from keydive.utils import xmldec, dumps
|
||||
|
||||
|
||||
class Core:
|
||||
"""
|
||||
Core class for managing DRM operations and interactions with Android devices.
|
||||
"""
|
||||
class Server:
|
||||
|
||||
def __init__(self, adb: ADB, cdm: Cdm, functions: Path = None, skip: bool = False):
|
||||
def __init__(self, version: str):
|
||||
"""
|
||||
Initializes a Core instance.
|
||||
Initialize the Server instance with the provided Frida version string.
|
||||
|
||||
Parameters:
|
||||
adb (ADB): ADB instance for device communication.
|
||||
cdm (Cdm): Instance for handling DRM-related operations.
|
||||
functions (Path, optional): Path to Ghidra XML file for symbol extraction. Defaults to None.
|
||||
skip (bool, optional): Whether to skip predefined functions (e.g., OEM_CRYPTO_API). Defaults to False.
|
||||
This constructor sets internal flags based on the Frida version,
|
||||
particularly to determine support for features introduced in Frida 16.6.0+.
|
||||
|
||||
Args:
|
||||
version (str): The version string of the Frida server (e.g., "16.5.3").
|
||||
"""
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
self.running = True
|
||||
self.cdm = cdm
|
||||
self.adb = adb
|
||||
self.logger = logging.getLogger('Server')
|
||||
|
||||
# Flag to skip predefined functions based on the vendor's API level
|
||||
# https://github.com/hyugogirubato/KeyDive/issues/38#issuecomment-2411932679
|
||||
self.skip = skip
|
||||
self.version = version
|
||||
self.logger.debug('Frida version: %s', self.version)
|
||||
|
||||
# Load the hook script with relevant data and prepare for injection
|
||||
self.functions = functions
|
||||
self.script = self.__prepare_hook_script()
|
||||
self.logger.info("Hook script prepared successfully")
|
||||
# Parse version string and determine feature support
|
||||
code = tuple(map(int, version.split('.')))
|
||||
self.features = code[0] > 16 or (code[0] == 16 and code[1] >= 6)
|
||||
self.logger.debug('Feature support: %s', self.features)
|
||||
|
||||
def __prepare_hook_script(self) -> str:
|
||||
# Used to prevent repeated user warnings in logs or dialogs
|
||||
self.dialog = False
|
||||
|
||||
|
||||
class Core(Remote):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
serial: Optional[str] = None,
|
||||
timeout: int = 5,
|
||||
symbols: Optional[Path] = None,
|
||||
detect: bool = True,
|
||||
disabler: bool = True,
|
||||
unencrypt: bool = False
|
||||
):
|
||||
"""
|
||||
Prepares the hook script by injecting library-specific data.
|
||||
Initialize the Core object, which manages interaction with a remote device
|
||||
and handles Widevine CDM (Content Decryption Module) processes.
|
||||
|
||||
This constructor also prepares a Frida script for hooking and sets up
|
||||
internal state for later process attachment and monitoring.
|
||||
|
||||
Args:
|
||||
serial (Optional[str]): The serial number of the connected Android device.
|
||||
If None, the first available device will be used.
|
||||
timeout (int): Timeout in seconds for establishing the device connection.
|
||||
symbols (Optional[Path]): Path to an XML file containing symbol definitions.
|
||||
detect (bool): Whether to enable detection logic in the injected Frida script.
|
||||
disabler (bool): Whether to enable anti-tamper or anti-debugging disablers
|
||||
in the Frida script.
|
||||
unencrypt (bool): If True, forces license challenge data to be sent unencrypted.
|
||||
"""
|
||||
# Initialize base Remote object with optional serial and connection timeout
|
||||
super().__init__(serial=serial, timeout=timeout)
|
||||
self.logger = logging.getLogger('Core')
|
||||
|
||||
# Indicates whether the watchdog process is actively running
|
||||
self._running = False
|
||||
|
||||
# Server instance (initialized later when Frida is attached)
|
||||
self._server: Optional[Server] = None
|
||||
|
||||
# Load and prepare the Frida script with optional symbol resolution
|
||||
self._script, self._resolved = self.__hook_script(
|
||||
detect=detect,
|
||||
disabler=disabler,
|
||||
path=symbols,
|
||||
unencrypt=unencrypt
|
||||
)
|
||||
|
||||
# Initialize CDM (Content Decryption Module) interface with SDK and disabler flag
|
||||
self.cdm = Cdm(sdk=self.sdk, disabler=disabler)
|
||||
|
||||
def __hook_script(
|
||||
self, detect: bool = True,
|
||||
disabler: bool = True,
|
||||
path: Optional[Path] = None,
|
||||
unencrypt: bool = False
|
||||
) -> Tuple[str, bool]:
|
||||
"""
|
||||
Prepares and customizes a JavaScript hook script with runtime configurations and optional symbol resolution.
|
||||
|
||||
This function reads a base JavaScript file (`keydive.js`), injects configurations and API stubs into it,
|
||||
and optionally replaces placeholders with resolved symbol names extracted from an XML file describing
|
||||
native functions.
|
||||
|
||||
Args:
|
||||
detect (bool): Whether to enable the detection logic in the hook script.
|
||||
disabler (bool): Whether to enable the disabler logic in the hook script.
|
||||
path (Optional[Path]): An optional path to an XML file containing function metadata (e.g., Ghidra-exported data).
|
||||
If provided, function symbols are parsed and injected into the script.
|
||||
unencrypt (bool): If True, configures the hook script to force client ID and related license
|
||||
challenge data to remain unencrypted.
|
||||
|
||||
Returns:
|
||||
str: The finalized hook script content with placeholders replaced.
|
||||
Tuple[str, bool]: A tuple where:
|
||||
- str is the fully prepared hook script as a string.
|
||||
- bool indicates whether symbol data was successfully loaded and injected.
|
||||
|
||||
Exception:
|
||||
Exceptions raised while reading or parsing the symbol file are caught and logged.
|
||||
"""
|
||||
# Read the base JavaScript template file
|
||||
content = Path(__file__).with_name("keydive.js").read_text(encoding="utf-8")
|
||||
# Load the base hook JavaScript file
|
||||
script = Path(__file__).with_name('keydive.js').read_text(encoding='utf-8')
|
||||
|
||||
# Generate the list of symbols from the functions file
|
||||
symbols = self.__prepare_symbols(self.functions)
|
||||
symbols = {}
|
||||
if path:
|
||||
try:
|
||||
# Parse the XML file containing function metadata (e.g., Ghidra export)
|
||||
content = xmldec(path.read_bytes(), force_list=['FUNCTION', 'STACK_VAR', 'REGISTER_VAR'])
|
||||
program = content['PROGRAM']
|
||||
addr_base = int(program['@IMAGE_BASE'], 16)
|
||||
functions = program['FUNCTIONS']['FUNCTION']
|
||||
|
||||
# Define the placeholder replacements
|
||||
replacements = {
|
||||
"${OEM_CRYPTO_API}": json.dumps(list(OEM_CRYPTO_API)),
|
||||
"${NATIVE_C_API}": json.dumps(list(NATIVE_C_API)),
|
||||
"${SYMBOLS}": json.dumps(symbols),
|
||||
"${SKIP}": str(self.skip)
|
||||
# Build a dictionary of function address to name mappings
|
||||
for f in functions:
|
||||
address = hex(int(f['@ENTRY_POINT'], 16) - addr_base)
|
||||
name = f['@NAME']
|
||||
|
||||
# Avoid duplicate entries by checking existing symbol names
|
||||
if name not in symbols.values():
|
||||
symbols[address] = name
|
||||
|
||||
self.logger.info('Successfully loaded %d symbol(s) from: %s', len(symbols), path.as_posix())
|
||||
except Exception as e:
|
||||
# Log the error if symbol import fails, but continue script generation
|
||||
self.logger.error('Unable to import symbols from XML: %s', e)
|
||||
|
||||
# Create the placeholder-to-value mapping
|
||||
placeholders = {
|
||||
'${OEM_CRYPTO_API}': dumps(OEM_CRYPTO_API),
|
||||
'${NATIVE_C_API}': dumps(NATIVE_C_API),
|
||||
'${SYMBOLS}': dumps(symbols),
|
||||
'${DETECT}': str(detect),
|
||||
'${DISABLER}': str(disabler),
|
||||
'${UNENCRYPT}': str(unencrypt)
|
||||
}
|
||||
|
||||
# Replace placeholders in the script content
|
||||
for placeholder, value in replacements.items():
|
||||
content = content.replace(placeholder, value, 1)
|
||||
# Replace placeholders in the script with actual data
|
||||
for placeholder, value in placeholders.items():
|
||||
script = script.replace(placeholder, value, 1)
|
||||
|
||||
return content
|
||||
# Return the modified script and a flag indicating if symbol injection was successful
|
||||
return script, bool(symbols)
|
||||
|
||||
def __prepare_symbols(self, path: Path) -> list:
|
||||
def launch(self, action: Literal['web', 'player'] = 'player') -> None:
|
||||
"""
|
||||
Extracts relevant functions from a Ghidra XML file.
|
||||
Launches a DRM-enabled player either as a native application or a web-based player.
|
||||
|
||||
Parameters:
|
||||
path (Path): Path to the Ghidra XML functions file.
|
||||
Depending on the selected action, this method ensures that the DRM player app is installed and running,
|
||||
or opens a predefined DRM test page in the system browser. It performs basic checks for installation,
|
||||
process existence, and handles optional installation if needed.
|
||||
|
||||
Returns:
|
||||
list: List of selected functions as dictionaries.
|
||||
Args:
|
||||
action (Literal['web', 'player']):
|
||||
- 'player': Launch the native DRM player app.
|
||||
- 'web': Open the web-based DRM player in the default browser.
|
||||
"""
|
||||
if action == 'player':
|
||||
# Retrieve the package name and human-readable name of the DRM player
|
||||
player_package = DRM_PLAYER['package']
|
||||
player_name = DRM_PLAYER['name']
|
||||
|
||||
self.logger.info('Preparing DRM player: %s (%s)', player_name, player_package)
|
||||
|
||||
# Check if the application is already installed
|
||||
installed = player_package in self.enumerate_applications(user=True, system=False)
|
||||
if installed:
|
||||
self.logger.debug('Application is already installed: %s', player_package)
|
||||
else:
|
||||
self.logger.debug('Application not found: %s. Attempting to install...', player_package)
|
||||
|
||||
# Try to install the app from the specified path or URL
|
||||
if not self.install_application(path=DRM_PLAYER['path'], url=DRM_PLAYER['url']):
|
||||
return # Stop if installation fails
|
||||
self.logger.debug('Application installed successfully: %s', player_package)
|
||||
|
||||
# Check if the application is already running
|
||||
player_pid = self.enumerate_processes(names=[player_package])
|
||||
if player_pid:
|
||||
self.logger.warning('Application is already running: %s (%s)', player_name, player_package)
|
||||
else:
|
||||
# Attempt to start the application
|
||||
self.logger.info('Starting application: %s (%s)', player_name, player_package)
|
||||
if not self.start_application(player_package):
|
||||
return # Abort if unable to launch
|
||||
|
||||
# Give the system a moment to register the new process
|
||||
time.sleep(1)
|
||||
player_pid = self.enumerate_processes(names=[player_package])
|
||||
|
||||
# Log running process info or fallback if PID is unknown
|
||||
if player_pid:
|
||||
self.logger.debug('Running process: %s (%s)', list(player_pid.keys())[-1], player_package)
|
||||
else:
|
||||
self.logger.debug('Unable to determine PID (%s).', player_package)
|
||||
|
||||
elif action == 'web':
|
||||
# Attempt to open the DRM test URL in the default browser
|
||||
self.logger.info('Opening DRM web player in default browser...')
|
||||
if not self.open_url(DRM_WEB):
|
||||
return
|
||||
|
||||
# Attempt to detect a running browser process
|
||||
player_pid = self.enumerate_processes(names=[
|
||||
'com.android.chrome', # Google Chrome
|
||||
'com.sec.android.app.sbrowser', # Samsung Internet
|
||||
'org.mozilla.firefox' # Mozilla Firefox
|
||||
])
|
||||
|
||||
if player_pid:
|
||||
self.logger.debug('Running process: %s (%s)', list(player_pid.keys())[-1], list(player_pid.values())[-1])
|
||||
else:
|
||||
self.logger.debug('No known browser process detected. Consider adding support for your browser.')
|
||||
|
||||
def watchdog(
|
||||
self,
|
||||
output: Path,
|
||||
delay: int = 1,
|
||||
auto_stop: bool = True,
|
||||
wvd: bool = False,
|
||||
keybox: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Continuously monitor Widevine DRM processes, export device credentials, and save to disk.
|
||||
|
||||
This method acts as a watchdog that periodically scans for Widevine-capable processes,
|
||||
attaches hooks to them, and exports relevant DRM credential files to a specified output directory.
|
||||
It supports optional exporting in .wvd format and OEM keyboxes.
|
||||
|
||||
Args:
|
||||
output (Path): Directory path where extracted files will be saved.
|
||||
delay (int, optional): Delay in seconds between each monitoring iteration. Defaults to 1.
|
||||
auto_stop (bool, optional): Automatically stop after successful export of client_id.bin. Defaults to True.
|
||||
wvd (bool, optional): Enable exporting credentials in .wvd format compatible with pywidevine. Defaults to False.
|
||||
keybox (bool, optional): Enable exporting OEM certificates and keyboxes if available. Defaults to False.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the functions file is not found.
|
||||
ValueError: If functions extraction fails.
|
||||
EnvironmentError: Raised if no Widevine DRM process is detected during scanning.
|
||||
"""
|
||||
# Return an empty list if no path is provided
|
||||
if not path:
|
||||
return []
|
||||
self.logger.info('Watcher delay: %ss' % delay)
|
||||
self._running = True
|
||||
|
||||
try:
|
||||
# Parse the XML file and extract program data
|
||||
program = xmltodict.parse(path.read_bytes())["PROGRAM"]
|
||||
addr_base = int(program["@IMAGE_BASE"], 16) # Base address for function addresses
|
||||
functions = program["FUNCTIONS"]["FUNCTION"] # List of functions in the XML
|
||||
extracted = [] # Tracks already exported files to avoid duplicates
|
||||
current = None # Holds the PID of the currently hooked Widevine process
|
||||
|
||||
# Identify a target function from the predefined OEM_CRYPTO_API list (if not skipped)
|
||||
target = next((f["@NAME"] for f in functions if f["@NAME"] in OEM_CRYPTO_API and not self.skip), None)
|
||||
while self._running:
|
||||
# Export device credentials and DRM data files
|
||||
files = self.cdm.export(wvd=wvd, keybox=keybox)
|
||||
if files:
|
||||
for name, data in files.items():
|
||||
# Sanitize file path to ensure valid filesystem names and create directories
|
||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146958022
|
||||
path = output / sanitize_filepath(name)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Prepare a dictionary to store selected functions
|
||||
selected = {}
|
||||
for func in functions:
|
||||
name = func["@NAME"] # Function name
|
||||
args = len(func.get("REGISTER_VAR", [])) # Number of arguments
|
||||
# If the file already exists and is identical, log a warning (only once) and skip
|
||||
if path.is_file() and path.read_bytes() == data:
|
||||
if path not in extracted:
|
||||
self.logger.warning('File already exists: %s', path)
|
||||
extracted.append(path)
|
||||
continue
|
||||
|
||||
"""
|
||||
Add the function if it matches specific criteria
|
||||
- Match the target function if identified
|
||||
- Match API keywords
|
||||
- Match unnamed functions with 6+ args
|
||||
"""
|
||||
if name not in selected and (
|
||||
name == target
|
||||
or any(True if self.skip else keyword in name for keyword in CDM_FUNCTION_API)
|
||||
or (not target and re.match(r"^[a-z]+$", name) and args >= 6)
|
||||
):
|
||||
selected[name] = {
|
||||
"type": "function",
|
||||
"name": name,
|
||||
"address": hex(int(func["@ENTRY_POINT"], 16) - addr_base) # Calculate relative address
|
||||
}
|
||||
# If exporting a .wvd file, remove any other .wvd files in the same directory
|
||||
if path.suffix == '.wvd' and path.parent.exists():
|
||||
for file in path.parent.glob('*.wvd'):
|
||||
file.unlink()
|
||||
|
||||
# Return the list of selected functions
|
||||
return list(selected.values())
|
||||
except FileNotFoundError as e:
|
||||
raise FileNotFoundError(f"Functions file not found: {path}") from e
|
||||
except Exception as e:
|
||||
raise ValueError("Failed to extract functions from Ghidra XML file") from e
|
||||
# Write the file
|
||||
self.logger.info('Exporting file: %s', path)
|
||||
path.write_bytes(data)
|
||||
extracted.append(path)
|
||||
|
||||
# If the client ID has been exported and auto-stop is enabled, stop monitoring
|
||||
if 'client_id.bin' in dumps(extracted):
|
||||
self._running = not auto_stop
|
||||
|
||||
if not self._running:
|
||||
self.logger.info('Required files exported successfully.')
|
||||
continue
|
||||
|
||||
# Detect Widevine-capable processes running on the device
|
||||
processes = sorted(
|
||||
[
|
||||
(pid, (name, vendor))
|
||||
for pid, name in self.enumerate_processes().items()
|
||||
for vendor in CDM_VENDOR_API
|
||||
if vendor.min_sdk <= self.sdk and vendor.is_process(name)
|
||||
],
|
||||
key=lambda item: item[1][1].min_sdk,
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if not processes:
|
||||
# No DRM process detected, raise error with guidance for the user
|
||||
raise EnvironmentError(
|
||||
'No Widevine DRM process detected. '
|
||||
'Refer to: https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#drm-info'
|
||||
)
|
||||
|
||||
# If previously hooked process is no longer running, clear current hook
|
||||
if current and current not in [pid for pid, _ in processes]:
|
||||
self.logger.warning('Widevine process terminated or replaced')
|
||||
current = None
|
||||
|
||||
# If no current hooked process, attempt to hook one from detected processes
|
||||
if not current:
|
||||
self.logger.debug('Scanning for Widevine processes...')
|
||||
for pid, (name, vendor) in processes:
|
||||
self.logger.info('Detected process: %s (%s)', pid, name)
|
||||
if self.__hook_process(pid, vendor):
|
||||
current = pid
|
||||
break
|
||||
|
||||
if current:
|
||||
self.logger.info('Successfully attached hook to process: %s', current)
|
||||
else:
|
||||
self.logger.warning('Widevine library not located yet. Retrying...')
|
||||
|
||||
# Sleep before next monitoring iteration to reduce resource usage
|
||||
time.sleep(delay)
|
||||
|
||||
def __process_message(self, message: dict, data: bytes) -> None:
|
||||
"""
|
||||
Handles messages received from the Frida script.
|
||||
Callback handler for messages sent from the Frida-injected script.
|
||||
|
||||
Parameters:
|
||||
message (dict): The message payload.
|
||||
data (bytes): The raw data associated with the message.
|
||||
This function processes various types of messages and data received from the Frida instrumentation layer.
|
||||
It routes them to appropriate CDM (Content Decryption Module) handlers or logs them accordingly.
|
||||
|
||||
Args:
|
||||
message (dict): The message dictionary sent from the Frida script.
|
||||
data (bytes): The associated binary payload, if any.
|
||||
"""
|
||||
logger = logging.getLogger("Script")
|
||||
level = message.get("payload")
|
||||
logger = logging.getLogger('Script')
|
||||
level = message.get('payload')
|
||||
|
||||
if isinstance(level, int):
|
||||
# Log the message based on its severity level
|
||||
logger.log(level=level, msg=data.decode("utf-8"))
|
||||
# Log text message from script with given severity level
|
||||
logger.log(level=level, msg=data.decode('utf-8'))
|
||||
|
||||
# Stop the runner if a critical or fatal error is reported
|
||||
if level in (logging.FATAL, logging.CRITICAL):
|
||||
self.running = False # Stop the process on critical errors
|
||||
elif isinstance(level, dict) and "private_key" in level:
|
||||
# Set the private key in the DRM handler
|
||||
self.cdm.set_private_key(data=data, name=level["private_key"])
|
||||
elif level == "challenge":
|
||||
# Set the challenge data in the DRM handler
|
||||
self.cdm.set_challenge(data=data)
|
||||
elif level == "device_id":
|
||||
# Set the device ID in the DRM handler
|
||||
self.cdm.set_device_id(data)
|
||||
elif level == "keybox":
|
||||
# Set the keybox data in the DRM handler
|
||||
self.running = False
|
||||
|
||||
elif isinstance(level, dict) and 'private_key' in level:
|
||||
self.cdm.set_private_key(data, level['private_key'])
|
||||
elif level == 'challenge':
|
||||
self.cdm.set_challenge(data)
|
||||
elif level == 'client_id':
|
||||
self.cdm.set_client_id(data)
|
||||
elif level == 'keybox':
|
||||
self.cdm.set_keybox(data)
|
||||
elif level == 'stable_id':
|
||||
self.cdm.set_stable_id(data)
|
||||
elif level == 'device_id':
|
||||
self.cdm.set_device_id(data)
|
||||
elif level == 'provisioning_method':
|
||||
self.cdm.set_provisioning_method(data)
|
||||
elif level == 'provisioning_response':
|
||||
self.cdm.set_provisioning_response(data)
|
||||
elif message.get('type') == 'error':
|
||||
# Log any script-side errors
|
||||
logger.error(message['description'])
|
||||
else:
|
||||
# Fallback logging for unrecognized messages
|
||||
logger.warning(message, data)
|
||||
|
||||
def hook_process(self, pid: int, vendor: Vendor, timeout: int = 0) -> bool:
|
||||
def __hook_process(self, pid: int, vendor: Vendor, timeout: int = 0) -> bool:
|
||||
"""
|
||||
Hooks into the specified process.
|
||||
Hooks into a running process using Frida to enable dynamic analysis and instrumentation.
|
||||
|
||||
Parameters:
|
||||
pid (int): The process ID to hook.
|
||||
vendor (Vendor): Instance of Vendor class representing the vendor information.
|
||||
timeout (int, optional): Timeout for attaching to the process. Defaults to 0.
|
||||
This method attaches to a process identified by its PID, loads a Frida script for hooking into
|
||||
a vendor-specific shared library, and optionally enables symbolic analysis depending on device
|
||||
and server capabilities.
|
||||
|
||||
Args:
|
||||
pid (int): Process ID of the target application to attach to.
|
||||
vendor (Vendor): A Vendor object containing metadata about the expected shared library.
|
||||
timeout (int, optional): Time (in seconds) to persist the Frida session. Defaults to 0.
|
||||
|
||||
Returns:
|
||||
bool: True if the process was successfully hooked, otherwise False.
|
||||
bool: True if the hook was successful, False otherwise.
|
||||
|
||||
Raises:
|
||||
EnvironmentError: If the Frida server is not running on the target device.
|
||||
"""
|
||||
try:
|
||||
# Attach to the target process using the specified PID.
|
||||
# The 'persist_timeout' parameter ensures the session persists for the given duration.
|
||||
session: Session = self.adb.device.attach(pid, persist_timeout=timeout)
|
||||
except frida.ServerNotRunningError as e:
|
||||
# Handle the case where the Frida server is not running on the device.
|
||||
raise EnvironmentError("Frida server is not running") from e
|
||||
# Attach to the target process using the specified PID
|
||||
# The 'persist_timeout' parameter ensures the session persists for the given duration
|
||||
session: Session = self.socket.attach(pid, persist_timeout=timeout)
|
||||
except ServerNotRunningError as e:
|
||||
# Handle the case where the Frida server is not running on the device
|
||||
raise EnvironmentError('Frida server is not running on the device.') from e
|
||||
except Exception as e:
|
||||
# Log other exceptions and return False to indicate failure.
|
||||
self.logger.error(e)
|
||||
# Catch and log all other errors that occur during session attachment
|
||||
self.logger.error('Could not attach to process %s: %s', pid, e)
|
||||
return False
|
||||
|
||||
# Define a callback to handle when the process is destroyed.
|
||||
# Define cleanup behavior for when the Frida script is destroyed
|
||||
def __process_destroyed() -> None:
|
||||
session.detach()
|
||||
|
||||
# Create a Frida script object using the prepared script content.
|
||||
script: Script = session.create_script(self.script)
|
||||
script.on("message", self.__process_message)
|
||||
script.on("destroyed", __process_destroyed)
|
||||
# Create and load the Frida script for dynamic analysis
|
||||
script: Script = session.create_script(self._script)
|
||||
script.on('message', self.__process_message) # Set message handler
|
||||
script.on('destroyed', __process_destroyed) # Set destruction handler
|
||||
script.load()
|
||||
|
||||
# Fetch a list of libraries loaded by the target process.
|
||||
# Fetch Frida server metadata (e.g., version) and store it if not already set
|
||||
if not self._server:
|
||||
self._server = Server(script.exports_sync.getversion())
|
||||
|
||||
# Retrieve the list of loaded libraries in the target process
|
||||
libraries = script.exports_sync.getlibraries()
|
||||
library = next((l for l in libraries if re.match(vendor.pattern, l["name"])), None)
|
||||
library = vendor.get_library(libraries)
|
||||
|
||||
if library:
|
||||
# Log information about the library if it is found.
|
||||
self.logger.info("Library: %s (%s)", library["name"], library["path"])
|
||||
# Log details about the matching library
|
||||
self.logger.info('Library found: %s (%s)', library['name'], library['path'])
|
||||
|
||||
# Retrieve and log the version of the Frida server.
|
||||
version = script.exports_sync.getversion()
|
||||
self.logger.debug(f"Server: %s", version)
|
||||
# Provide context-aware warnings about symbol resolution options
|
||||
if not self._server.dialog:
|
||||
if self._server.features and self._resolved:
|
||||
self.logger.warning('The "--symbols" option is deprecated in Frida 16.6.0 and newer.')
|
||||
elif not self._server.features and vendor.min_oem[0] < 18 and self._resolved:
|
||||
self.logger.warning('The "--symbols" option is deprecated for OEM API versions below 18.')
|
||||
elif not self._server.features and vendor.min_oem[0] > 17 and not self._resolved:
|
||||
self.logger.warning(
|
||||
'For OEM API > 17, the "--symbols" option is required. '
|
||||
'Refer to: https://github.com/hyugogirubato/KeyDive/blob/main/docs/FUNCTIONS.md'
|
||||
)
|
||||
self._server.dialog = True
|
||||
|
||||
# Determine if the Frida server version is older than 16.6.0.
|
||||
code = tuple(map(int, version.split(".")))
|
||||
minimum = code[0] > 16 or (code[0] == 16 and code[1] >= 6)
|
||||
# Determine whether to enable dynamic symbol resolution based on server and vendor context
|
||||
dynamic = self._server.features and vendor.min_oem[0] > 17 and not self._resolved
|
||||
|
||||
# Warn the user if certain conditions related to the functions option are met.
|
||||
if minimum and self.functions:
|
||||
self.logger.warning("The '--functions' option is deprecated starting from Frida 16.6.0")
|
||||
elif not minimum and vendor.oem < 18 and self.functions:
|
||||
self.logger.warning("The '--functions' option is deprecated for OEM API < 18")
|
||||
elif not minimum and vendor.oem > 17 and not self.functions:
|
||||
self.logger.warning("For OEM API > 17, specifying '--functions' is required. Refer to https://github.com/hyugogirubato/KeyDive/blob/main/docs/FUNCTIONS.md")
|
||||
# Attempt to hook into the identified library with or without dynamic symbols
|
||||
return script.exports_sync.hooklibrary(library['name'], dynamic)
|
||||
|
||||
# Enable dynamic analysis (symbols) only when necessary
|
||||
dynamic = minimum and vendor.oem > 17 and not self.functions
|
||||
return script.exports_sync.hooklibrary(library["name"], dynamic)
|
||||
|
||||
# Unload the script if the target library is not found.
|
||||
# If the expected library was not found, clean up and notify the user
|
||||
script.unload()
|
||||
self.logger.warning("Library not found: %s" % vendor.pattern)
|
||||
self.logger.warning('Expected library not found: %s' % vendor.library)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ("Core",)
|
||||
__all__ = ('Core',)
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
from keydive.adb.vendor import Vendor
|
||||
|
||||
# Maximum clear API level for Keybox
|
||||
# https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:trusty/user/app/sample/hwcrypto/keybox/keybox.c
|
||||
KEYBOX_MAX_CLEAR_API = 28
|
||||
|
||||
# https://cs.android.com/search?q=oemcrypto&sq=&ss=android%2Fplatform%2Fsuperproject
|
||||
OEM_CRYPTO_API = (
|
||||
# Mapping of function names across different API levels (obfuscated names may vary).
|
||||
'rnmsglvj', 'polorucp', 'kqzqahjq', 'pldrclfq', 'kgaitijd', 'cwkfcplc', 'crhqcdet', 'ulns', 'dnvffnze', 'ygjiljer',
|
||||
'qbjxtubz', 'qkfrcjtw', 'rbhjspoh', 'zgtjmxko', 'igrqajte', 'ofskesua', 'qllcoacg', 'pukctkiv', 'ehdqmfmd',
|
||||
'xftzvkwx', 'gndskkuk', 'wcggmnnx', 'kaatohcz', 'ktmgdchz', 'jkcwonus', 'ehmduqyt', 'vewtuecx', 'mxrbzntq',
|
||||
'isyowgmp', 'flzfkhbc', 'rtgejgqb', 'sxxprljw', 'ebxjbtxl', 'pcmtpkrj', 'uegpdzus', 'ncmqbmbc'
|
||||
# Add more as needed for different versions.
|
||||
)
|
||||
|
||||
# https://developers.google.com/widevine
|
||||
CDM_FUNCTIONS = (
|
||||
'UsePrivacyMode',
|
||||
'GetCdmClientPropertySet',
|
||||
'PrepareKeyRequest',
|
||||
'AesCbcKey',
|
||||
'Read', '_x1c36',
|
||||
'runningcrc',
|
||||
'_oecc07', '_lcc07',
|
||||
'_oecc04', '_lcc04',
|
||||
'_oecc49', '_lcc49',
|
||||
'_oecc12', '_lcc12',
|
||||
'_oecc95', '_lcc95',
|
||||
'_oecc21', '_lcc21',
|
||||
'GenerateDerivedKeys'
|
||||
)
|
||||
|
||||
# https://developer.android.com/tools/releases/platforms
|
||||
CDM_VENDOR_API = (
|
||||
Vendor('mediaserver', 22, (11, '1.0'), 'libwvdrmengine.so'),
|
||||
Vendor('mediadrmserver', 24, (11, '1.0'), 'libwvdrmengine.so'),
|
||||
Vendor('android.hardware.drm@1.0-service.widevine', 26, (13, '5.1.0'), 'libwvhidl.so'),
|
||||
Vendor('android.hardware.drm@1.1-service.widevine', 28, (14, '14.0.0'), 'libwvhidl.so'),
|
||||
Vendor('android.hardware.drm@1.2-service.widevine', 29, (15, '15.0.0'), 'libwvhidl.so'),
|
||||
Vendor('android.hardware.drm@1.3-service.widevine', 30, (16, '16.0.0'), 'libwvhidl.so'),
|
||||
Vendor('android.hardware.drm@1.4-service.widevine', 31, (16, '16.1.0'), 'libwvhidl.so'),
|
||||
Vendor('android.hardware.drm-service.widevine', 33, (17, '17.0.0'), 'libwvaidl.so'),
|
||||
Vendor('android.hardware.drm-service.widevine', 34, (18, '18.0.0'), 'android.hardware.drm-service.widevine')
|
||||
)
|
||||
|
||||
# https://github.com/kaltura/kaltura-device-info-android/blob/master/app/src/main/java/com/kaltura/kalturadeviceinfo/Collector.java#L317
|
||||
CDM_PROPERTIES = {
|
||||
'CompanyName': 'ro.product.manufacturer',
|
||||
'ModelName': 'ro.product.model',
|
||||
'ArchitectureName': 'ro.product.cpu.abi',
|
||||
'DeviceName': 'ro.product.device',
|
||||
'ProductName': 'ro.product.name',
|
||||
'BuildInfo': 'ro.build.fingerprint'
|
||||
}
|
||||
+1000
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
|
||||
from construct import BitStruct, Bytes, Const
|
||||
from construct import Enum as CEnum
|
||||
from construct import Int8ub, Int16ub
|
||||
from construct import Optional as COptional
|
||||
from construct import Padded, Padding, Struct, this
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
||||
|
||||
from keydive.drm.protocol.license_pb2 import ClientIdentification
|
||||
|
||||
|
||||
class DeviceTypes(Enum):
|
||||
CHROME = 1
|
||||
ANDROID = 2
|
||||
|
||||
|
||||
class _Structures:
|
||||
magic = Const(b"WVD")
|
||||
|
||||
header = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Int8ub
|
||||
)
|
||||
|
||||
v2 = Struct(
|
||||
"signature" / magic,
|
||||
"version" / Const(2, Int8ub),
|
||||
"type_" / CEnum(
|
||||
Int8ub,
|
||||
**{t.name: t.value for t in DeviceTypes}
|
||||
),
|
||||
"security_level" / Int8ub,
|
||||
"flags" / Padded(1, COptional(BitStruct(
|
||||
# no per-device flags yet
|
||||
Padding(8)
|
||||
))),
|
||||
"private_key_len" / Int16ub,
|
||||
"private_key" / Bytes(this.private_key_len),
|
||||
"client_id_len" / Int16ub,
|
||||
"client_id" / Bytes(this.client_id_len)
|
||||
)
|
||||
|
||||
|
||||
class Device:
|
||||
"""
|
||||
A minimal implementation of a WVD (Widevine Device) structure,
|
||||
modeled after the pywidevine library.
|
||||
|
||||
This class provides a lightweight alternative to the full `pywidevine.Device`,
|
||||
retaining only the essential fields for constructing and serializing Widevine device
|
||||
blobs in version 2 format, without performing certificate validation or VMP handling.
|
||||
"""
|
||||
Structures = _Structures
|
||||
supported_structure = Structures.v2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
type_: DeviceTypes,
|
||||
security_level: int,
|
||||
flags: Optional[dict],
|
||||
private_key: RSAPrivateKey,
|
||||
client_id: ClientIdentification,
|
||||
):
|
||||
"""
|
||||
Initialize the Device object.
|
||||
|
||||
Args:
|
||||
type_ (DeviceTypes or str): The type of device.
|
||||
security_level (int): The DRM security level (e.g., 1, 3).
|
||||
flags (Optional[dict]): Optional feature flags (reserved for future use).
|
||||
private_key (RSAPrivateKey): The device's RSA private key.
|
||||
client_id (ClientIdentification): The protobuf client identifier.
|
||||
"""
|
||||
self.type = DeviceTypes[type_] if isinstance(type_, str) else type_
|
||||
self.security_level = security_level
|
||||
self.flags = flags or {}
|
||||
self.private_key = private_key
|
||||
self.client_id = client_id
|
||||
|
||||
def dumps(self) -> bytes:
|
||||
"""
|
||||
Serialize the Device object into a WVD-compatible binary format.
|
||||
|
||||
Returns:
|
||||
bytes: A binary representation of the device data.
|
||||
"""
|
||||
# Serialize the client ID protobuf message
|
||||
bin_client_id = self.client_id.SerializeToString()
|
||||
|
||||
# Serialize the private RSA key to DER format
|
||||
bin_private_key = self.private_key.private_bytes(
|
||||
encoding=serialization.Encoding.DER,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
)
|
||||
|
||||
# Build the structured binary representation
|
||||
return self.supported_structure.build(dict(
|
||||
version=2,
|
||||
type_=self.type.value,
|
||||
security_level=self.security_level,
|
||||
flags=self.flags,
|
||||
private_key_len=len(bin_private_key),
|
||||
private_key=bin_private_key,
|
||||
client_id_len=len(bin_client_id),
|
||||
client_id=bin_client_id
|
||||
))
|
||||
|
||||
|
||||
__all__ = ('Device', 'DeviceTypes')
|
||||
@@ -0,0 +1,243 @@
|
||||
from uuid import UUID
|
||||
|
||||
from crccheck.crc import Crc32Mpeg2
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher
|
||||
from cryptography.hazmat.primitives.ciphers.algorithms import AES
|
||||
from cryptography.hazmat.primitives.ciphers.modes import ECB
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
# Convert bytes to a big-endian unsigned integer
|
||||
bytes2int = lambda x: int.from_bytes(x, byteorder='big', signed=False)
|
||||
|
||||
|
||||
class KeyBox:
|
||||
"""
|
||||
Represents a binary DRM keybox containing a device's unique identifiers and AES key.
|
||||
|
||||
A KeyBox is composed of:
|
||||
- A 32-byte stable ID
|
||||
- A 16-byte AES key
|
||||
- A 72-byte device ID blob (may contain encrypted metadata)
|
||||
- A 4-byte 'kbox' tag
|
||||
- A 4-byte CRC32 checksum
|
||||
- [Optional] A 4-byte 'LVL1' tag (QSEE-specific)
|
||||
|
||||
This class supports both parsing and serialization of this binary format.
|
||||
"""
|
||||
|
||||
MAGIC_TAG = b'kbox' # Magic tag required at byte offset 120–124
|
||||
LEVEL_TAG = b'LVL1' # Optional level tag used by QSEE environments (byte offset 128–132)
|
||||
|
||||
def __init__(self):
|
||||
self._stable_id = b'' # 32-byte stable device ID
|
||||
self._device_aes_key = b'' # 16-byte AES encryption key
|
||||
self._device_id = b'' # 72-byte encrypted or encoded device-specific ID blob
|
||||
|
||||
@property
|
||||
def stable_id(self) -> bytes:
|
||||
"""
|
||||
Returns the stable ID of the device (32 bytes).
|
||||
|
||||
Returns:
|
||||
bytes: The stable device ID.
|
||||
"""
|
||||
return self._stable_id
|
||||
|
||||
@stable_id.setter
|
||||
def stable_id(self, value: bytes) -> None:
|
||||
"""
|
||||
Sets the 32-byte stable ID of the device.
|
||||
|
||||
Args:
|
||||
value (bytes): A 32-byte stable ID.
|
||||
|
||||
Raises:
|
||||
AssertionError: If `value` is not exactly 32 bytes long.
|
||||
"""
|
||||
assert len(value) == 32, f'Invalid stable ID length: expected 32 bytes, got {len(value)}'
|
||||
self._stable_id = value
|
||||
|
||||
@property
|
||||
def device_aes_key(self) -> bytes:
|
||||
"""
|
||||
Returns the AES encryption key used by the device (16 bytes).
|
||||
|
||||
Returns:
|
||||
bytes: The device AES key.
|
||||
"""
|
||||
return self._device_aes_key
|
||||
|
||||
@device_aes_key.setter
|
||||
def device_aes_key(self, value: bytes) -> None:
|
||||
"""
|
||||
Sets the 16-byte AES key for the device.
|
||||
|
||||
Args:
|
||||
value (bytes): A 16-byte AES key.
|
||||
|
||||
Raises:
|
||||
AssertionError: If `value` is not exactly 16 bytes long.
|
||||
"""
|
||||
assert len(value) == 16, f'Invalid AES key length: expected 16 bytes, got {len(value)}'
|
||||
self._device_aes_key = value
|
||||
|
||||
@property
|
||||
def device_id(self) -> bytes:
|
||||
"""
|
||||
Returns the 72-byte device ID blob.
|
||||
|
||||
Returns:
|
||||
bytes: The device-specific identifier blob.
|
||||
"""
|
||||
return self._device_id
|
||||
|
||||
@device_id.setter
|
||||
def device_id(self, value: bytes) -> None:
|
||||
"""
|
||||
Sets the device ID blob (72 bytes).
|
||||
|
||||
Args:
|
||||
value (bytes): A 72-byte device ID.
|
||||
|
||||
Raises:
|
||||
AssertionError: If `value` is not exactly 72 bytes long.
|
||||
"""
|
||||
assert len(value) == 72, f'Invalid device ID length: expected 72 bytes, got {len(value)}'
|
||||
self._device_id = value
|
||||
|
||||
def ParseFromString(self, serialized: bytes) -> None:
|
||||
"""
|
||||
Parses and validates a binary keybox structure into its individual fields.
|
||||
|
||||
The expected format is:
|
||||
- 32 bytes: stable ID
|
||||
- 16 bytes: AES key
|
||||
- 72 bytes: device ID
|
||||
- 4 bytes: keybox tag (must be "kbox")
|
||||
- 4 bytes: CRC32 checksum (MPEG-2 variant)
|
||||
- [Optional] 4 bytes: level tag ("LVL1") for QSEE devices
|
||||
|
||||
Args:
|
||||
serialized (bytes): Raw keybox data (128 or 132 bytes).
|
||||
|
||||
Raises:
|
||||
AssertionError: If data is malformed or fails validation.
|
||||
"""
|
||||
# https://github.com/zybpp/Python/tree/master/Python/keybox
|
||||
# Size of the entire serialized keybox (should be either 128 bytes for standard keybox,
|
||||
# or 132 bytes for QSEE variant which appends an additional 4-byte level tag)
|
||||
size = len(serialized)
|
||||
|
||||
# Validate that the size is either of the two expected formats
|
||||
assert size in (128, 132), f'Invalid keybox size: expected 128 or 132 bytes, got {size}'
|
||||
|
||||
# Slice fields from the input
|
||||
self.stable_id = serialized[0:32] # Device's unique identifier (32 bytes)
|
||||
self.device_aes_key = serialized[32:48] # Device cryptographic key (16 bytes)
|
||||
self.device_id = serialized[48:120] # Token for device authentication (72 bytes)
|
||||
|
||||
magic_tag = serialized[120:124] # Magic tag (4 bytes)
|
||||
body_crc = bytes2int(serialized[124:128]) # CRC32 checksum (4 bytes)
|
||||
level_tag = serialized[128:132] # Optional level tag (4 bytes)
|
||||
|
||||
# Ensure the 4-byte magic tag is exactly "kbox"
|
||||
assert magic_tag == self.MAGIC_TAG, 'Keybox tag mismatch: expected "kbox"'
|
||||
|
||||
# Validate the checksum using CRC32 MPEG-2 algorithm on the first 124 bytes
|
||||
# This ensures data integrity and prevents accidental corruption or tampering
|
||||
assert body_crc == Crc32Mpeg2.calc(serialized[:124]), 'CRC32 validation failed'
|
||||
|
||||
# If the optional QSEE tag is present, ensure it exactly matches "LVL1"
|
||||
# This tag typically denotes that the keybox is trusted or verified by Qualcomm Secure Execution Environment
|
||||
assert size == 128 or level_tag == self.LEVEL_TAG, 'Unexpected QSEE tag: expected "LVL1"'
|
||||
|
||||
@property
|
||||
def device_info(self) -> dict:
|
||||
"""
|
||||
Attempts to decode device_id contents into meaningful fields.
|
||||
|
||||
The layout is decoded if the first 4 bytes (flag) are <= 10.
|
||||
Otherwise, the format is assumed encrypted or proprietary.
|
||||
|
||||
Returns:
|
||||
dict: Decoded fields such as 'flag', 'system_id', 'provisioning_id', and 'encrypted_bits'.
|
||||
Returns an empty dict if layout is unknown.
|
||||
"""
|
||||
# Interpret device_id fields if the flag is within expected range (<= 10)
|
||||
flag = bytes2int(self.device_id[0:4])
|
||||
if not flag > 10:
|
||||
infos = {
|
||||
'flag': flag, # Device flag (4 bytes)
|
||||
'system_id': bytes2int(self.device_id[4:8]), # System identifier (4 bytes)
|
||||
'provisioning_id': UUID(bytes_le=self.device_id[8:24]), # Provisioning UUID (16 bytes)
|
||||
'encrypted_bits': self.device_id[24:72] # Encrypted device-specific information (48 bytes)
|
||||
}
|
||||
else:
|
||||
# Encrypted format or unknown layout; optionally decrypt if format is known
|
||||
# https://github.com/ThatNotEasy/Parser-DRM/blob/main/modules/widevine.py#L84
|
||||
"""
|
||||
# Padding to AES block size (16 bytes)
|
||||
padder = PKCS7(AES.block_size).padder()
|
||||
enc_padded_data = padder.update(self.device_id[0:4]) + padder.finalize()
|
||||
|
||||
# Create AES-ECB cipher with device key
|
||||
cipher = Cipher(
|
||||
algorithm=AES(self.device_aes_key),
|
||||
mode=ECB(),
|
||||
backend=default_backend()
|
||||
)
|
||||
|
||||
# Decrypt padded data
|
||||
decryptor = cipher.decryptor()
|
||||
dec_padded_data = decryptor.update(enc_padded_data) + decryptor.finalize()
|
||||
|
||||
# Remove padding to get original data
|
||||
unpadder = PKCS7(AES.block_size).unpadder()
|
||||
dec_data = unpadder.update(dec_padded_data) + unpadder.finalize()
|
||||
"""
|
||||
infos = {}
|
||||
|
||||
return infos
|
||||
|
||||
@property
|
||||
def keybox_info(self) -> dict:
|
||||
"""
|
||||
Extracts all readable keybox metadata for external consumption.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing raw and interpreted keybox data.
|
||||
"""
|
||||
return {
|
||||
'stable_id': self.stable_id,
|
||||
'device_aes_key': self.device_aes_key,
|
||||
'device_id': self.device_id,
|
||||
**self.device_info
|
||||
}
|
||||
|
||||
def SerializeToString(self) -> bytes:
|
||||
"""
|
||||
Serializes the current keybox structure to a binary blob.
|
||||
|
||||
Returns:
|
||||
bytes: A 128-byte serialized keybox with checksum.
|
||||
|
||||
Raises:
|
||||
AssertionError: If any required field is missing.
|
||||
"""
|
||||
# Ensure that all essential components are set; without them, a valid keybox cannot be constructed
|
||||
assert self.stable_id and self.device_aes_key and self.device_id, 'Cannot serialize: one or more required fields are missing'
|
||||
|
||||
# This forms the first 124 bytes of the final binary output.
|
||||
serialized = self.stable_id + self.device_aes_key + self.device_id + self.MAGIC_TAG
|
||||
|
||||
# Calculate a CRC32 checksum using the MPEG-2 variant on the first 124 bytes
|
||||
# This helps verify data integrity when the structure is later read or validated
|
||||
body_crc = Crc32Mpeg2.calc(serialized)
|
||||
|
||||
# Convert the CRC integer into a 4-byte big-endian format and append it to the structure
|
||||
# This completes the full 128-byte keybox format required by consumers or hardware modules
|
||||
return serialized + int.to_bytes(body_crc, length=4, byteorder='big', signed=False)
|
||||
|
||||
|
||||
__all__ = ('KeyBox',)
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,180 +0,0 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from json.encoder import encode_basestring_ascii
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def bytes2int(value: bytes, byteorder: Literal["big", "little"] = "big", signed: bool = False) -> int:
|
||||
"""
|
||||
Convert a byte sequence to an integer.
|
||||
|
||||
Parameters:
|
||||
value (bytes): The byte sequence to convert.
|
||||
byteorder (str, optional): Byte order for conversion. 'big' or 'little'. Defaults to 'big'.
|
||||
signed (bool, optional): Whether the integer is signed. Defaults to False.
|
||||
|
||||
Returns:
|
||||
int: The integer representation of the byte sequence.
|
||||
"""
|
||||
return int.from_bytes(value, byteorder=byteorder, signed=signed)
|
||||
|
||||
|
||||
class Keybox:
|
||||
"""
|
||||
The Keybox class handles the storage and management of device IDs and keybox data.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initializes the Keybox object, setting up logger and containers for device IDs and keyboxes.
|
||||
"""
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
# https://github.com/kaltura/kaltura-device-info-android/blob/master/app/src/main/java/com/kaltura/kalturadeviceinfo/MainActivity.java#L203
|
||||
self.device_id = []
|
||||
self.keybox = {}
|
||||
|
||||
def set_device_id(self, data: bytes) -> None:
|
||||
"""
|
||||
Set the device ID from the provided data.
|
||||
|
||||
Parameters:
|
||||
data (bytes): The device ID, expected to be 32 bytes long.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the data length is not 32 bytes.
|
||||
"""
|
||||
try:
|
||||
size = len(data)
|
||||
# Ensure the device ID is exactly 32 bytes long
|
||||
assert size == 32, f"Invalid device ID length: {size}. Should be 32 bytes"
|
||||
|
||||
# Add device ID to the list if it's not already present
|
||||
if data not in self.device_id:
|
||||
self.logger.info("Receive device id: \n\n%s\n", encode_basestring_ascii(data.decode("utf-8")))
|
||||
self.device_id.append(data)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.debug("Failed to set device id: %s", e)
|
||||
|
||||
def set_keybox(self, data: bytes) -> None:
|
||||
"""
|
||||
Set the keybox from the provided data.
|
||||
|
||||
Parameters:
|
||||
data (bytes): The keybox data, expected to be either 128 or 132 bytes long.
|
||||
|
||||
Raises:
|
||||
AssertionError: If the data length is not 128 or 132 bytes or does not meet other criteria.
|
||||
"""
|
||||
# https://github.com/zybpp/Python/tree/master/Python/keybox
|
||||
try:
|
||||
size = len(data)
|
||||
# Validate the keybox size (128 or 132 bytes)
|
||||
assert size in (128, 132), f"Invalid keybox length: {size}. Should be 128 or 132 bytes"
|
||||
|
||||
# Validate the QSEE-style keybox end
|
||||
assert size == 128 or data[128:132] == b"LVL1", "QSEE-style keybox must end with bytes 'LVL1'"
|
||||
|
||||
# Validate the keybox magic (should be 'kbox')
|
||||
assert data[120:124] == b"kbox", "Invalid keybox magic"
|
||||
|
||||
device_id = data[0:32] # Extract the device ID from the first 32 bytes
|
||||
|
||||
# Retrieve and log the structured keybox information
|
||||
infos = self.__keybox_info(data)
|
||||
encrypted = infos["flags"] > 10 # Check if the keybox is encrypted
|
||||
self.set_device_id(data=device_id) # Set the device ID
|
||||
|
||||
# Log and store the keybox data if it's a new keybox or the device ID is updated
|
||||
if (device_id in self.keybox and self.keybox[device_id] != (data, encrypted)) or device_id not in self.keybox:
|
||||
self.logger.info("Receive keybox: \n\n%s\n", json.dumps(infos, indent=2))
|
||||
|
||||
# Warn if keybox is encrypted and interception of plaintext device token is needed
|
||||
if encrypted:
|
||||
self.logger.warning("Keybox contains encrypted data. Interception of plaintext device token is needed")
|
||||
|
||||
# Store the keybox (encrypted or not) for the device ID
|
||||
if (device_id in self.keybox and not encrypted) or device_id not in self.keybox:
|
||||
self.keybox[device_id] = (data, encrypted)
|
||||
except Exception as e:
|
||||
self.logger.debug("Failed to set keybox: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def __keybox_info(data: bytes) -> dict:
|
||||
"""
|
||||
Extract keybox information from the provided data.
|
||||
|
||||
Parameters:
|
||||
data (bytes): The keybox data.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary containing extracted keybox information.
|
||||
"""
|
||||
# https://github.com/wvdumper/dumper/blob/main/Helpers/Keybox.py#L51
|
||||
|
||||
# Extract device-specific information from the keybox data
|
||||
device_token = data[48:120]
|
||||
|
||||
# Prepare the keybox content dictionary
|
||||
content = {
|
||||
"device_id": data[0:32].decode("utf-8"), # Device's unique identifier (32 bytes)
|
||||
"device_key": data[32:48], # Device cryptographic key (16 bytes)
|
||||
"device_token": device_token, # Token for device authentication (72 bytes)
|
||||
"keybox_tag": data[120:124].decode("utf-8"), # Magic tag (4 bytes)
|
||||
"crc32": bytes2int(data[124:128]), # CRC32 checksum (4 bytes)
|
||||
"level_tag": data[128:132].decode("utf-8") or None, # Optional level tag (4 bytes)
|
||||
|
||||
# Extract metadata from the device token (Bytes 48–120)
|
||||
"flags": bytes2int(device_token[0:4]), # Device flags (4 bytes)
|
||||
"system_id": bytes2int(device_token[4:8]), # System identifier (4 bytes)
|
||||
"provisioning_id": UUID(bytes_le=device_token[8:24]), # Provisioning UUID (16 bytes)
|
||||
"encrypted_bits": device_token[24:72] # Encrypted device-specific information (48 bytes)
|
||||
}
|
||||
|
||||
# https://github.com/ThatNotEasy/Parser-DRM/blob/main/modules/widevine.py#L84
|
||||
# TODO: decrypt device token value
|
||||
|
||||
# Encode bytes as base64 and convert UUIDs to string
|
||||
return {
|
||||
k: base64.b64encode(v).decode("utf-8") if isinstance(v, bytes) else str(v) if isinstance(v, UUID) else v
|
||||
for k, v in content.items()
|
||||
}
|
||||
|
||||
def export(self, parent: Path) -> bool:
|
||||
"""
|
||||
Export the keybox data to a file in the specified parent directory.
|
||||
|
||||
Parameters:
|
||||
parent (Path): The parent directory where the keybox data will be saved.
|
||||
|
||||
Returns:
|
||||
bool: True if any keybox were exported, otherwise False.
|
||||
"""
|
||||
# Find matching keyboxes based on the device_id
|
||||
keys = self.device_id & self.keybox.keys()
|
||||
|
||||
for k in keys:
|
||||
# Create the parent directory if it doesn't exist
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Define the export file path and extension (encrypted or binary)
|
||||
path_keybox_bin = parent / ("keybox." + ("enc" if self.keybox[k][1] else "bin"))
|
||||
|
||||
# Write the keybox data to the file
|
||||
path_keybox_bin.write_bytes(self.keybox[k][0])
|
||||
|
||||
# Log export status based on whether the keybox is encrypted
|
||||
if self.keybox[k][1]:
|
||||
self.logger.warning("Exported encrypted keybox: %s", path_keybox_bin)
|
||||
else:
|
||||
self.logger.info("Exported keybox: %s", path_keybox_bin)
|
||||
|
||||
# Return True if any keyboxes were exported, otherwise False
|
||||
return len(keys) > 0
|
||||
|
||||
|
||||
__all__ = ("Keybox",)
|
||||
+592
-225
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import coloredlogs
|
||||
import xmltodict
|
||||
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
def unidec(data: str) -> str:
|
||||
"""
|
||||
Normalizes a string by removing diacritics and accents using ASCII transliteration.
|
||||
|
||||
Args:
|
||||
data (str): The input string to normalize.
|
||||
|
||||
Returns:
|
||||
str: The transliterated and stripped version of the input string.
|
||||
"""
|
||||
return unidecode(data).strip()
|
||||
|
||||
|
||||
def b64enc(data: Union[bytes, str], safe: bool = False) -> str:
|
||||
"""
|
||||
Encodes data to Base64 format.
|
||||
|
||||
Args:
|
||||
data (bytes or str): The data to encode.
|
||||
safe (bool): Use URL-safe encoding if True.
|
||||
|
||||
Returns:
|
||||
str: Base64-encoded string.
|
||||
"""
|
||||
if isinstance(data, str):
|
||||
data = data.encode('utf-8')
|
||||
return (base64.urlsafe_b64encode(data) if safe else base64.b64encode(data)).decode('utf-8')
|
||||
|
||||
|
||||
def b64dec(data: str, safe: bool = False) -> bytes:
|
||||
"""
|
||||
Decodes Base64-encoded string to bytes.
|
||||
|
||||
Args:
|
||||
data (str): Base64-encoded string.
|
||||
safe (bool): Use URL-safe decoding if True.
|
||||
|
||||
Returns:
|
||||
bytes: Decoded byte data.
|
||||
"""
|
||||
# Fix missing padding
|
||||
data = data + '=' * (-len(data) % 4)
|
||||
return base64.urlsafe_b64decode(data) if safe else base64.b64decode(data)
|
||||
|
||||
|
||||
def xmldec(data: Union[bytes, str], force_list: Optional[list] = None) -> dict:
|
||||
"""
|
||||
Parses XML string or bytes into a dictionary.
|
||||
|
||||
Args:
|
||||
data (str or bytes): The XML data to parse.
|
||||
force_list (list, optional): Tags to always parse as lists.
|
||||
|
||||
Returns:
|
||||
dict: Parsed XML structure.
|
||||
"""
|
||||
return xmltodict.parse(data, force_list=set(force_list or []))
|
||||
|
||||
|
||||
def dumps(data: Union[dict, list], beauty: bool = False) -> str:
|
||||
"""
|
||||
Serializes a Python object into JSON, handling binary, nested, and custom types.
|
||||
|
||||
Args:
|
||||
data (dict or list): The data to serialize.
|
||||
beauty (bool): If True, formats JSON with indentation for readability.
|
||||
|
||||
Returns:
|
||||
str: A JSON-formatted string.
|
||||
"""
|
||||
|
||||
def __string(value):
|
||||
# Recursively process lists
|
||||
if isinstance(value, list):
|
||||
return [__string(v) for v in value]
|
||||
|
||||
# Recursively process dictionaries
|
||||
elif isinstance(value, dict):
|
||||
return {__string(k): __string(v) for k, v in value.items()}
|
||||
|
||||
# Handle byte values (e.g., raw data, encoded JSON, binary keys)
|
||||
elif isinstance(value, bytes):
|
||||
if value:
|
||||
# Try decoding as JSON
|
||||
try:
|
||||
return __string(json.loads(value))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Try decoding as UTF-8 string
|
||||
try:
|
||||
return value.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
|
||||
# Handle specific known binary formats (e.g., 16-byte AES keys)
|
||||
if len(value) == 16:
|
||||
return value.hex()
|
||||
|
||||
# Fallback: encode to base64 string for general binary data
|
||||
return b64enc(value)
|
||||
|
||||
# Return None for empty bytes
|
||||
return None
|
||||
|
||||
# Recurse again for nested structures
|
||||
elif isinstance(value, (dict, list)):
|
||||
return __string(value)
|
||||
|
||||
# Convert known non-serializable types to string
|
||||
elif isinstance(value, (UUID, datetime, Path)):
|
||||
return str(value)
|
||||
|
||||
# If value is a string, try parsing it as JSON
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
return __string(json.loads(value))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Return all other values unchanged
|
||||
return value
|
||||
|
||||
# Perform final JSON serialization with optional pretty-printing
|
||||
return json.dumps(
|
||||
__string(data),
|
||||
indent=2 if beauty else None,
|
||||
separators=None if beauty else (',', ':') # Compact output if not pretty
|
||||
)
|
||||
|
||||
|
||||
def configure_logging(path: Optional[Path] = None, verbose: bool = False) -> Optional[Path]:
|
||||
"""
|
||||
Configures logging to file and console with optional verbosity.
|
||||
|
||||
Args:
|
||||
path (Path, optional): Directory to save the log file.
|
||||
verbose (bool): Enable debug-level logging if True.
|
||||
|
||||
Returns:
|
||||
Path or None: Path to log file if created, otherwise None.
|
||||
"""
|
||||
# Set up the root logger with the desired logging level
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
# Clear any existing handlers (optional, to avoid duplicate logs if reconfiguring)
|
||||
if root_logger.hasHandlers():
|
||||
root_logger.handlers.clear()
|
||||
|
||||
file_path = None
|
||||
if path:
|
||||
# Ensure the log directory exists
|
||||
if path.is_file():
|
||||
path = path.parent
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Create a file handler
|
||||
file_path = path / ('keydive_%s.log' % datetime.now().strftime('%Y-%m-%d_%H-%M-%S'))
|
||||
file_path = file_path.resolve(strict=False)
|
||||
file_handler = logging.FileHandler(file_path)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# Set log formatting
|
||||
formatter = logging.Formatter(
|
||||
fmt='%(asctime)s [%(levelname).1s] %(name)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
|
||||
# Add the file handler to the root logger
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Configure coloredlogs for console output
|
||||
# 'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'
|
||||
coloredlogs.install(
|
||||
fmt='%(asctime)s [%(levelname).1s] %(name)s: %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S',
|
||||
level=logging.DEBUG if verbose else logging.INFO,
|
||||
logger=root_logger,
|
||||
field_styles={
|
||||
'asctime': {'color': 'green'}, # timestamp
|
||||
'hostname': {'color': 'magenta'},
|
||||
'levelname': {'bold': True, 'color': 'blue'}, # level e.g., [I]
|
||||
'name': {'color': 'magenta'}, # logger name
|
||||
'programname': {'color': 'cyan'},
|
||||
'message': {'color': 'white'} # message text
|
||||
},
|
||||
level_styles={
|
||||
'debug': {'color': 'cyan'},
|
||||
'info': {'color': 'white'},
|
||||
'warning': {'color': 'yellow'},
|
||||
'error': {'color': 'red'},
|
||||
'critical': {'bold': True, 'color': 'red'}
|
||||
}
|
||||
)
|
||||
|
||||
return file_path
|
||||
@@ -1,34 +0,0 @@
|
||||
class Vendor:
|
||||
"""
|
||||
Represents a Vendor with SDK, OEM, version, and name attributes.
|
||||
"""
|
||||
|
||||
def __init__(self, sdk: int, oem: int, version: str, pattern: str):
|
||||
"""
|
||||
Initializes a Vendor instance.
|
||||
|
||||
Parameters:
|
||||
sdk (int): Minimum SDK version required by the vendor.
|
||||
oem (int): OEM identifier for the vendor.
|
||||
version (str): Version of the vendor.
|
||||
pattern (str): Name pattern of the vendor.
|
||||
"""
|
||||
self.sdk = sdk
|
||||
self.oem = oem
|
||||
self.version = version
|
||||
self.pattern = pattern
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the Vendor instance.
|
||||
|
||||
Returns:
|
||||
str: String representation of the Vendor instance with its attributes.
|
||||
"""
|
||||
return "{name}({items})".format(
|
||||
name=self.__class__.__name__,
|
||||
items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||
)
|
||||
|
||||
|
||||
__all__ = ("Vendor",)
|
||||
Reference in New Issue
Block a user