mirror of
https://github.com/hyugogirubato/KeyDive.git
synced 2026-07-15 18:40:02 +02:00
Release v2.0.0
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
from .core import Core
|
||||
from .cdm import Cdm
|
||||
from .vendor import Vendor
|
||||
|
||||
__version__ = '2.0.0'
|
||||
@@ -0,0 +1,171 @@
|
||||
import argparse
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import coloredlogs
|
||||
|
||||
import keydive
|
||||
from keydive.cdm import Cdm
|
||||
from keydive.constants import CDM_VENDOR_API
|
||||
from keydive.core import Core
|
||||
|
||||
|
||||
def configure_logging(path: Path, verbose: bool) -> Path:
|
||||
"""
|
||||
Configures logging for the application.
|
||||
|
||||
Args:
|
||||
path (Path, optional): The path for log files.
|
||||
verbose (bool): Whether to enable verbose logging.
|
||||
|
||||
Returns:
|
||||
Path: The path of log file.
|
||||
"""
|
||||
# Get the root logger
|
||||
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:
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description='Extract Widevine L3 keys from an Android device.')
|
||||
|
||||
# Global options
|
||||
opt_global = parser.add_argument_group('Global options')
|
||||
opt_global.add_argument('-d', '--device', required=False, type=str, metavar='<id>', help='Specify the target Android device ID to connect with via ADB.')
|
||||
opt_global.add_argument('-v', '--verbose', required=False, action='store_true', help='Enable verbose logging for detailed debug output.')
|
||||
opt_global.add_argument('-l', '--log', required=False, type=Path, metavar='<dir>', help='Directory to store log files.')
|
||||
opt_global.add_argument('--delay', required=False, type=float, metavar='<delay>', default=1, help='Delay (in seconds) between process checks in the watcher.')
|
||||
opt_global.add_argument('--version', required=False, action='store_true', help='Display KeyDive version information.')
|
||||
|
||||
# Cdm options
|
||||
opt_cdm = parser.add_argument_group('Cdm options')
|
||||
opt_cdm.add_argument('-a', '--auto', required=False, action='store_true', help='Automatically open Bitmovin\'s demo.')
|
||||
opt_cdm.add_argument('-c', '--challenge', required=False, type=Path, metavar='<file>', help='Path to unencrypted challenge for extracting client ID.')
|
||||
opt_cdm.add_argument('-w', '--wvd', required=False, action='store_true', help='Generate WVD.')
|
||||
opt_cdm.add_argument('-o', '--output', required=False, type=Path, default=Path('device'), metavar='<dir>', help='Output directory path for extracted data.')
|
||||
opt_cdm.add_argument('-f', '--functions', required=False, type=Path, metavar='<file>', help='Path to Ghidra XML functions file.')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.version:
|
||||
print(f'KeyDive {keydive.__version__}')
|
||||
exit(0)
|
||||
|
||||
# Configure logging
|
||||
log_path = configure_logging(path=args.log, verbose=args.verbose)
|
||||
logger = logging.getLogger('KeyDive')
|
||||
logger.info('Version: %s', keydive.__version__)
|
||||
|
||||
try:
|
||||
# Start the ADB server if not already running
|
||||
sp = subprocess.run(['adb', 'start-server'], capture_output=True)
|
||||
if sp.returncode != 0:
|
||||
raise EnvironmentError('ADB is not recognized as an environment variable, refer to https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge')
|
||||
|
||||
# Initialize Cdm instance
|
||||
cdm = Cdm()
|
||||
if args.challenge:
|
||||
cdm.set_challenge(data=args.challenge)
|
||||
|
||||
# Initialize Core instance for interacting with the device
|
||||
core = Core(cdm=cdm, device=args.device, symbols=args.functions)
|
||||
|
||||
# Process watcher loop
|
||||
logger.info('Watcher delay: %ss' % args.delay)
|
||||
current = None
|
||||
while core.running:
|
||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146788792
|
||||
processes = {
|
||||
key: (name, pid)
|
||||
for name, pid in core.enumerate_processes().items()
|
||||
for key in CDM_VENDOR_API.keys() if key 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:
|
||||
if current not in [v[1] for v in processes.values()]:
|
||||
logger.warning('Widevine process has changed')
|
||||
current = None
|
||||
elif cdm.export(args.output, args.wvd):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
# 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.')
|
||||
if args.auto:
|
||||
logger.info('Starting DRM player launch process...')
|
||||
sp = subprocess.run(['adb', '-s', str(core.device.id), 'shell', 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', 'https://bitmovin.com/demos/drm'], capture_output=True)
|
||||
if sp.returncode != 0:
|
||||
logger.error('Error launching DRM player: %s' % sp.stdout.decode('utf-8').strip())
|
||||
else:
|
||||
logger.warning('Widevine library not found, searching...')
|
||||
|
||||
# Delay before next iteration
|
||||
time.sleep(args.delay)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.critical(e, exc_info=args.verbose)
|
||||
|
||||
# Final logging and exit
|
||||
if log_path:
|
||||
logger.info('Log file: %s' % log_path)
|
||||
logger.info('Exiting')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from zlib import crc32
|
||||
|
||||
from Cryptodome.PublicKey import RSA
|
||||
from pywidevine import Device
|
||||
from pywidevine.device import DeviceTypes
|
||||
from pywidevine.license_protocol_pb2 import SignedMessage, LicenseRequest, ClientIdentification, SignedDrmCertificate, DrmCertificate
|
||||
from unidecode import unidecode
|
||||
|
||||
|
||||
def sanitize(path: Path) -> Path:
|
||||
"""
|
||||
Sanitizes the given path by replacing invalid characters.
|
||||
|
||||
Args:
|
||||
path (Path): The path to sanitize.
|
||||
|
||||
Returns:
|
||||
Path: The sanitized path.
|
||||
"""
|
||||
paths = [path.name, *[p.name for p in path.parents if p.name]][::-1]
|
||||
for i, p in enumerate(paths):
|
||||
p = p.replace('...', '').strip()
|
||||
p = re.sub(r'[<>:"/|?*\x00-\x1F]', '_', p)
|
||||
paths[i] = p
|
||||
|
||||
return Path().joinpath(*paths)
|
||||
|
||||
|
||||
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):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
# https://github.com/devine-dl/pywidevine
|
||||
self.client_id: dict[int, ClientIdentification] = {}
|
||||
self.private_key: dict[int, RSA] = {}
|
||||
|
||||
def __client_info(self, client_id: ClientIdentification) -> dict:
|
||||
"""
|
||||
Converts client identification information to a dictionary.
|
||||
|
||||
Args:
|
||||
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}
|
||||
|
||||
def set_challenge(self, data: Union[Path, bytes]) -> None:
|
||||
"""
|
||||
Sets the challenge data by extracting device information.
|
||||
|
||||
Args:
|
||||
data (Union[Path, bytes]): The challenge data as a file path or bytes.
|
||||
"""
|
||||
try:
|
||||
if isinstance(data, Path):
|
||||
if not data.is_file():
|
||||
raise FileNotFoundError(data)
|
||||
data = data.read_bytes()
|
||||
|
||||
signed_message = SignedMessage()
|
||||
signed_message.ParseFromString(data)
|
||||
|
||||
license_request = LicenseRequest()
|
||||
license_request.ParseFromString(signed_message.msg)
|
||||
|
||||
client_id: ClientIdentification = license_request.client_id
|
||||
self.set_client_id(data=client_id.SerializeToString())
|
||||
except Exception as e:
|
||||
self.logger.error('Error parsing challenge: %s', e)
|
||||
|
||||
def set_private_key(self, data: bytes) -> None:
|
||||
"""
|
||||
Sets the private key from the provided data.
|
||||
|
||||
Args:
|
||||
data (bytes): The private key data.
|
||||
"""
|
||||
try:
|
||||
key = RSA.import_key(data)
|
||||
if key.n not in self.private_key:
|
||||
self.logger.debug('Receive private key: \n\n%s\n', key.exportKey('PEM').decode('utf-8'))
|
||||
self.private_key[key.n] = key
|
||||
except Exception as e:
|
||||
self.logger.error('Error parsing private key: %s', e)
|
||||
|
||||
def set_client_id(self, data: Union[ClientIdentification, bytes]) -> None:
|
||||
"""
|
||||
Sets the client ID from the provided data.
|
||||
|
||||
Args:
|
||||
data (Union[ClientIdentification, bytes]): The client ID data.
|
||||
"""
|
||||
try:
|
||||
if isinstance(data, ClientIdentification):
|
||||
client_id = data
|
||||
else:
|
||||
client_id = ClientIdentification()
|
||||
client_id.ParseFromString(data)
|
||||
|
||||
signed_drm_certificate = SignedDrmCertificate()
|
||||
drm_certificate = DrmCertificate()
|
||||
|
||||
signed_drm_certificate.ParseFromString(client_id.token)
|
||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||
|
||||
public_key = drm_certificate.public_key
|
||||
key = RSA.importKey(public_key)
|
||||
|
||||
if key.n not in self.client_id:
|
||||
self.logger.debug('Receive client id: \n\n%s\n', json.dumps(self.__client_info(client_id), indent=2))
|
||||
self.client_id[key.n] = client_id
|
||||
except Exception as e:
|
||||
self.logger.error('Error parsing client ID: %s', e)
|
||||
|
||||
def export(self, parent: Path, wvd: bool = False) -> bool:
|
||||
"""
|
||||
Exports the client ID and private key to disk.
|
||||
|
||||
Args:
|
||||
parent (Path): The parent directory to export the files to.
|
||||
wvd (bool): Whether to export WVD files.
|
||||
|
||||
Returns:
|
||||
bool: True if any keys were exported, otherwise False.
|
||||
"""
|
||||
keys = set(self.client_id.keys()) & set(self.private_key.keys())
|
||||
for k in keys:
|
||||
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
|
||||
)
|
||||
|
||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146958022
|
||||
parent = sanitize(parent / client_info['company_name'] / client_info['model_name'] / str(device.system_id) / str(k)[:10])
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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)
|
||||
|
||||
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 wvd:
|
||||
wvd_bin = device.dumps()
|
||||
|
||||
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 / f'{name}_{device.system_id}_l{device.security_level}.wvd'
|
||||
|
||||
path_wvd.write_bytes(data=wvd_bin)
|
||||
self.logger.info('Exported WVD: %s', path_wvd)
|
||||
|
||||
return len(keys) > 0
|
||||
|
||||
|
||||
__all__ = ('Cdm',)
|
||||
@@ -0,0 +1,119 @@
|
||||
from keydive.vendor import Vendor
|
||||
|
||||
NATIVE_C_API = {
|
||||
# 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',
|
||||
# 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'
|
||||
}
|
||||
|
||||
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', 'ehmduqyt'
|
||||
# Add more as needed for different versions.
|
||||
}
|
||||
|
||||
CDM_VENDOR_API = {
|
||||
'mediaserver': {
|
||||
Vendor(11, '1.0', 'libwvdrmengine.so')
|
||||
},
|
||||
'mediadrmserver': {
|
||||
Vendor(11, '1.0', 'libwvdrmengine.so')
|
||||
},
|
||||
'android.hardware.drm@1.0-service.widevine': {
|
||||
Vendor(13, '5.1.0', 'libwvhidl.so')
|
||||
},
|
||||
'android.hardware.drm@1.1-service.widevine': {
|
||||
Vendor(14, '14.0.0', 'libwvhidl.so')
|
||||
},
|
||||
'android.hardware.drm@1.2-service.widevine': {
|
||||
Vendor(15, '15.0.0', 'libwvhidl.so')
|
||||
},
|
||||
'android.hardware.drm@1.3-service.widevine': {
|
||||
Vendor(16, '16.0.0', 'libwvhidl.so')
|
||||
},
|
||||
'android.hardware.drm@1.4-service.widevine': {
|
||||
Vendor(16, '16.1.0', 'libwvhidl.so')
|
||||
},
|
||||
'android.hardware.drm-service.widevine': {
|
||||
Vendor(17, '17.0.0', 'libwvaidl.so'),
|
||||
Vendor(18, '18.0.0', 'android.hardware.drm-service.widevine')
|
||||
}
|
||||
}
|
||||
|
||||
# https://developer.android.com/tools/releases/platforms
|
||||
CDM_FUNCTION_API = {
|
||||
'UsePrivacyMode',
|
||||
'GetCdmClientPropertySet',
|
||||
'PrepareKeyRequest',
|
||||
'getOemcryptoDeviceId'
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import frida
|
||||
import xmltodict
|
||||
from frida.core import Device, Session, Script
|
||||
|
||||
from keydive.cdm import Cdm
|
||||
from keydive.constants import OEM_CRYPTO_API, NATIVE_C_API, CDM_FUNCTION_API
|
||||
from keydive.vendor import Vendor
|
||||
|
||||
|
||||
class Core:
|
||||
"""
|
||||
Core class for handling DRM operations and device interactions.
|
||||
"""
|
||||
|
||||
def __init__(self, cdm: Cdm, device: str = None, symbols: Path = None):
|
||||
"""
|
||||
Initializes a Core instance.
|
||||
|
||||
Args:
|
||||
cdm (Cdm): Instance of Cdm for managing DRM related operations.
|
||||
device (str, optional): ID of the Android device to connect to via ADB. Defaults to None (uses USB device).
|
||||
symbols (Path, optional): Path to Ghidra XML functions file for symbol extraction. Defaults to None.
|
||||
"""
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
self.running = True
|
||||
self.cdm = cdm
|
||||
|
||||
# Select device based on provided ID or default to the first USB device.
|
||||
self.device: Device = frida.get_device(id=device, timeout=5) if device else frida.get_usb_device(timeout=5)
|
||||
self.logger.info('Device: %s (%s)', self.device.name, self.device.id)
|
||||
|
||||
# Obtain device properties
|
||||
properties = self.device_properties()
|
||||
self.logger.info('SDK API: %s', properties['ro.build.version.sdk'])
|
||||
self.logger.info('ABI CPU: %s', properties['ro.product.cpu.abi'])
|
||||
|
||||
# Load the hook script
|
||||
self.symbols = self.__prepare_symbols(symbols)
|
||||
self.script = self.__prepare_hook_script()
|
||||
self.logger.info('Script loaded successfully')
|
||||
|
||||
def __prepare_hook_script(self) -> str:
|
||||
"""
|
||||
Prepares the hook script content by injecting the library-specific scripts.
|
||||
|
||||
Returns:
|
||||
str: The prepared script content.
|
||||
"""
|
||||
content = Path(__file__).with_name('keydive.js').read_text()
|
||||
|
||||
# Replace placeholders in script template
|
||||
replacements = {
|
||||
'${OEM_CRYPTO_API}': json.dumps(list(OEM_CRYPTO_API)),
|
||||
'${NATIVE_C_API}': json.dumps(list(NATIVE_C_API)),
|
||||
'${SYMBOLS}': json.dumps(self.symbols)
|
||||
}
|
||||
|
||||
for placeholder, value in replacements.items():
|
||||
content = content.replace(placeholder, value)
|
||||
|
||||
return content
|
||||
|
||||
def __prepare_symbols(self, path: Path) -> list:
|
||||
"""
|
||||
Parses the provided XML functions file to select relevant functions.
|
||||
|
||||
Args:
|
||||
path (Path): Path to Ghidra XML functions file.
|
||||
|
||||
Returns:
|
||||
list: List of selected functions as dictionaries.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If the functions file is not found.
|
||||
ValueError: If functions extraction fails.
|
||||
"""
|
||||
if not path:
|
||||
return []
|
||||
elif not path.is_file():
|
||||
raise FileNotFoundError('Functions file not found')
|
||||
|
||||
try:
|
||||
program = xmltodict.parse(path.read_bytes())['PROGRAM']
|
||||
addr_base = int(program['@IMAGE_BASE'], 16)
|
||||
functions = program['FUNCTIONS']['FUNCTION']
|
||||
|
||||
# Find a target function from a predefined list
|
||||
target = next((f['@NAME'] for f in functions if f['@NAME'] in OEM_CRYPTO_API), None)
|
||||
|
||||
# Extract relevant functions
|
||||
selected = {}
|
||||
for func in functions:
|
||||
name = func['@NAME']
|
||||
args = len(func.get('REGISTER_VAR', []))
|
||||
|
||||
# Add function if it matches specific criteria
|
||||
if name not in selected and (
|
||||
name == target
|
||||
or any(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)
|
||||
}
|
||||
return list(selected.values())
|
||||
except Exception as e:
|
||||
raise ValueError('Failed to extract functions from Ghidra') from e
|
||||
|
||||
def device_properties(self) -> dict:
|
||||
"""
|
||||
Retrieves system properties from the connected device using ADB shell commands.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary of device properties.
|
||||
"""
|
||||
# https://source.android.com/docs/core/architecture/configuration/add-system-properties?#shell-commands
|
||||
properties = {}
|
||||
sp = subprocess.run(['adb', '-s', str(self.device.id), 'shell', 'getprop'], capture_output=True)
|
||||
for line in sp.stdout.decode('utf-8').splitlines():
|
||||
match = re.match(r'\[(.*?)\]: \[(.*?)\]', line)
|
||||
if match:
|
||||
key, value = match.groups()
|
||||
# Attempt to cast numeric and boolean values to appropriate types
|
||||
try:
|
||||
value = int(value)
|
||||
except ValueError:
|
||||
if value.lower() in ('true', 'false'):
|
||||
value = value.lower() == 'true'
|
||||
properties[key] = value
|
||||
return properties
|
||||
|
||||
def enumerate_processes(self) -> dict:
|
||||
"""
|
||||
Lists processes running on the device, returning a mapping of process names to PIDs.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary mapping process names to PIDs.
|
||||
"""
|
||||
processes = {}
|
||||
|
||||
# https://github.com/frida/frida/issues/1225#issuecomment-604181822
|
||||
prompt = ['adb', '-s', str(self.device.id), 'shell', 'ps']
|
||||
sp = subprocess.run([*prompt, '-A'], capture_output=True)
|
||||
if sp.returncode != 0:
|
||||
sp = subprocess.run(prompt, capture_output=True)
|
||||
|
||||
# Iterate through lines starting from the second line (skipping header)
|
||||
for line in sp.stdout.decode('utf-8').splitlines()[1:]:
|
||||
try:
|
||||
line = line.split() # USER,PID,PPID,VSZ,RSS,WCHAN,ADDR,S,NAME
|
||||
name = ' '.join(line[8:]).strip()
|
||||
name = name if name.startswith('[') else Path(name).name
|
||||
processes[name] = int(line[1])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return processes
|
||||
|
||||
def __process_message(self, message: dict, data: bytes) -> None:
|
||||
"""
|
||||
Handles messages received from the Frida script.
|
||||
|
||||
Args:
|
||||
message (dict): The message payload.
|
||||
data (bytes): The raw data associated with the message.
|
||||
"""
|
||||
logger = logging.getLogger('Script')
|
||||
level = message.get('payload')
|
||||
|
||||
if isinstance(level, int):
|
||||
# Process logging messages from Frida script
|
||||
logger.log(level=level, msg=data.decode('utf-8'))
|
||||
if level in (logging.FATAL, logging.CRITICAL):
|
||||
self.running = False
|
||||
elif level == 'challenge':
|
||||
self.cdm.set_challenge(data=data)
|
||||
elif level == 'private_key':
|
||||
self.cdm.set_private_key(data=data)
|
||||
elif level == 'client_id':
|
||||
self.cdm.set_client_id(data=data)
|
||||
else:
|
||||
self.logger.warning('Malformed message: %s -> %s' % (level, data))
|
||||
|
||||
def hook_process(self, pid: int, vendor: Vendor, timeout: int = 0) -> bool:
|
||||
"""
|
||||
Hooks into the specified process.
|
||||
|
||||
Args:
|
||||
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.
|
||||
|
||||
Returns:
|
||||
bool: True if the process was successfully hooked, otherwise False.
|
||||
"""
|
||||
try:
|
||||
session: Session = self.device.attach(pid, persist_timeout=timeout)
|
||||
except Exception as e:
|
||||
self.logger.error(e)
|
||||
return False
|
||||
|
||||
def __process_destroyed() -> None:
|
||||
session.detach()
|
||||
|
||||
script: Script = session.create_script(self.script)
|
||||
script.on('message', self.__process_message)
|
||||
script.on('destroyed', __process_destroyed)
|
||||
script.load()
|
||||
|
||||
library = script.exports_sync.getlibrary(vendor.name)
|
||||
if library:
|
||||
self.logger.info('Library: %s (%s)', library['name'], library['path'])
|
||||
|
||||
# Check if Ghidra XML functions loaded
|
||||
if vendor.oem > 17 and not self.symbols:
|
||||
self.logger.warning('For OEM API > 17, specifying "functions" is required, refer to https://github.com/hyugogirubato/KeyDive/blob/main/docs/FUNCTIONS.md')
|
||||
elif vendor.oem < 18 and self.symbols:
|
||||
self.logger.warning('The "functions" attribute is deprecated for OEM API < 18')
|
||||
|
||||
return script.exports_sync.hooklibrary(vendor.name)
|
||||
|
||||
script.unload()
|
||||
self.logger.warning('Library not found: %s' % vendor.name)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ('Core',)
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Date: 2024-06-30
|
||||
* Description: DRM key extraction for research and educational purposes.
|
||||
* Source: https://github.com/hyugogirubato/KeyDive
|
||||
*/
|
||||
|
||||
// Placeholder values dynamically replaced at runtime.
|
||||
const OEM_CRYPTO_API = JSON.parse('${OEM_CRYPTO_API}');
|
||||
const NATIVE_C_API = JSON.parse('${NATIVE_C_API}');
|
||||
const SYMBOLS = JSON.parse('${SYMBOLS}');
|
||||
|
||||
|
||||
// Logging levels to synchronize with Python's logging module.
|
||||
const Level = {
|
||||
NOTSET: 0,
|
||||
DEBUG: 10,
|
||||
INFO: 20,
|
||||
// WARN: WARNING,
|
||||
WARNING: 30,
|
||||
ERROR: 40,
|
||||
// FATAL: CRITICAL,
|
||||
CRITICAL: 50
|
||||
};
|
||||
|
||||
// Utility for encoding strings into byte arrays (UTF-8).
|
||||
// https://gist.github.com/Yaffle/5458286#file-textencodertextdecoder-js
|
||||
function TextEncoder() {
|
||||
}
|
||||
|
||||
TextEncoder.prototype.encode = function (string) {
|
||||
const octets = [];
|
||||
let i = 0;
|
||||
while (i < string.length) {
|
||||
const codePoint = string.codePointAt(i);
|
||||
let c = 0;
|
||||
let bits = 0;
|
||||
if (codePoint <= 0x007F) {
|
||||
c = 0;
|
||||
bits = 0x00;
|
||||
} else if (codePoint <= 0x07FF) {
|
||||
c = 6;
|
||||
bits = 0xC0;
|
||||
} else if (codePoint <= 0xFFFF) {
|
||||
c = 12;
|
||||
bits = 0xE0;
|
||||
} else if (codePoint <= 0x1FFFFF) {
|
||||
c = 18;
|
||||
bits = 0xF0;
|
||||
}
|
||||
octets.push(bits | (codePoint >> c));
|
||||
while (c >= 6) {
|
||||
c -= 6;
|
||||
octets.push(0x80 | ((codePoint >> c) & 0x3F));
|
||||
}
|
||||
i += codePoint >= 0x10000 ? 2 : 1;
|
||||
}
|
||||
return octets;
|
||||
};
|
||||
|
||||
// Simplified log function to handle messages and encode them for transport.
|
||||
const print = (level, message) => {
|
||||
message = message instanceof Object ? JSON.stringify(message) : message;
|
||||
message = message ? new TextEncoder().encode(message) : message;
|
||||
send(level, message);
|
||||
}
|
||||
|
||||
|
||||
// @Utils
|
||||
const getLibraries = (name) => {
|
||||
// https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146788792
|
||||
try {
|
||||
const libraries = Process.enumerateModules();
|
||||
return libraries.filter(l => l.name.includes(name));
|
||||
} catch (e) {
|
||||
print(Level.CRITICAL, e.message);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const getLibrary = (name) => {
|
||||
const libraries = getLibraries(name);
|
||||
return libraries.length === 1 ? libraries[0] : undefined;
|
||||
}
|
||||
|
||||
const getFunctions = (library) => {
|
||||
try {
|
||||
return library.enumerateExports();
|
||||
} catch (e) {
|
||||
print(Level.CRITICAL, e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// @Libraries
|
||||
const UsePrivacyMode = (address) => {
|
||||
// wvcdm::Properties::UsePrivacyMode
|
||||
Interceptor.replace(address, new NativeCallback(function () {
|
||||
return 0;
|
||||
}, 'int', []));
|
||||
|
||||
Interceptor.attach(address, {
|
||||
onEnter: function (args) {
|
||||
print(Level.DEBUG, '[+] onEnter: UsePrivacyMode');
|
||||
},
|
||||
onLeave: function (retval) {
|
||||
print(Level.DEBUG, '[-] onLeave: UsePrivacyMode');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const GetCdmClientPropertySet = (address) => {
|
||||
// wvcdm::Properties::GetCdmClientPropertySet
|
||||
Interceptor.replace(address, new NativeCallback(function () {
|
||||
return 0;
|
||||
}, 'int', []));
|
||||
|
||||
Interceptor.attach(address, {
|
||||
onEnter: function (args) {
|
||||
print(Level.DEBUG, '[+] onEnter: GetCdmClientPropertySet');
|
||||
},
|
||||
onLeave: function (retval) {
|
||||
print(Level.DEBUG, '[-] onLeave: GetCdmClientPropertySet');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const PrepareKeyRequest = (address) => {
|
||||
// wvcdm::CdmLicense::PrepareKeyRequest
|
||||
Interceptor.attach(address, {
|
||||
onEnter: function (args) {
|
||||
print(Level.DEBUG, '[+] onEnter: PrepareKeyRequest');
|
||||
|
||||
// https://github.com/hyugogirubato/KeyDive/issues/13#issue-2327487249
|
||||
this.params = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
this.params.push(args[i]);
|
||||
}
|
||||
},
|
||||
onLeave: function (retval) {
|
||||
print(Level.DEBUG, '[-] onLeave: PrepareKeyRequest');
|
||||
let dumped = false;
|
||||
|
||||
for (let i = 0; i < this.params.length; i++) {
|
||||
try {
|
||||
const param = ptr(this.params[i]);
|
||||
const size = Memory.readUInt(param.add(Process.pointerSize));
|
||||
const data = Memory.readByteArray(param.add(Process.pointerSize * 2).readPointer(), size);
|
||||
if (data) {
|
||||
dumped = true;
|
||||
send('challenge', data);
|
||||
}
|
||||
} catch (e) {
|
||||
// print(Level.WARNING, `Failed to dump data for arg ${i}`);
|
||||
}
|
||||
}
|
||||
!dumped && print(Level.ERROR, 'Failed to dump challenge.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const GetCertificatePrivateKey = (address, name) => {
|
||||
// wvcdm::CryptoSession::GetCertificatePrivateKey
|
||||
Interceptor.attach(address, {
|
||||
onEnter: function (args) {
|
||||
if (!args[6].isNull()) {
|
||||
const size = args[6].toInt32();
|
||||
if (size >= 1000 && size <= 2000 && !args[5].isNull()) {
|
||||
const buffer = args[5].readByteArray(size);
|
||||
const bytes = new Uint8Array(buffer);
|
||||
// Check for DER encoding markers for the beginning of a private key (MII).
|
||||
if (bytes[0] === 0x30 && bytes[1] === 0x82) {
|
||||
/*
|
||||
let key = bytes;
|
||||
try {
|
||||
// Fixing key size
|
||||
const binaryString = String.fromCharCode.apply(null, bytes);
|
||||
const keyLength = getKeyLength(binaryString); // ASN.1 DER
|
||||
key = bytes.slice(0, keyLength);
|
||||
} catch (e) {
|
||||
print(Level.ERROR, `${e.message} (${address})`);
|
||||
}
|
||||
*/
|
||||
print(Level.DEBUG, `[*] GetCertificatePrivateKey: ${name}`);
|
||||
!OEM_CRYPTO_API.includes(name) && print(Level.WARNING, `The function "${name}" does not belong to the referenced functions. Communicate it to the developer to improve the tool.`);
|
||||
send('private_key', bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onLeave: function (retval) {
|
||||
// print(Level.DEBUG, `[-] onLeave: ${name}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const getKeyLength = (key) => {
|
||||
// Skip the initial tag
|
||||
let pos = 1;
|
||||
// Extract length byte, ignoring the long-form indicator bit
|
||||
let lengthByte = key.charCodeAt(pos++) & 0x7F;
|
||||
// If lengthByte indicates a short form, return early.
|
||||
/*
|
||||
if (lengthByte < 0x80) {
|
||||
return pos + lengthByte;
|
||||
}
|
||||
*/
|
||||
|
||||
// For long-form, calculate the length value.
|
||||
let lengthValue = 0;
|
||||
while (lengthByte--) {
|
||||
lengthValue = (lengthValue << 8) + key.charCodeAt(pos++);
|
||||
}
|
||||
return pos + lengthValue;
|
||||
}
|
||||
|
||||
const GetDeviceId = (address, name) => {
|
||||
// wvcdm::Properties::GetCdmClientPropertySet
|
||||
Interceptor.attach(address, {
|
||||
onEnter: function (args) {
|
||||
print(Level.DEBUG, '[+] onEnter: getOemcryptoDeviceId');
|
||||
this.data = args[0];
|
||||
this.size = args[1];
|
||||
},
|
||||
onLeave: function (retval) {
|
||||
print(Level.DEBUG, '[-] onLeave: getOemcryptoDeviceId');
|
||||
try {
|
||||
const size = Memory.readPointer(this.size).toInt32();
|
||||
const data = Memory.readByteArray(this.data, size);
|
||||
data && send('client_id', data);
|
||||
} catch (e) {
|
||||
print(Level.ERROR, `Failed to dump device Id.`);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// @Hooks
|
||||
const hookLibrary = (name) => {
|
||||
// https://github.com/poxyran/misc/blob/master/frida-enumerate-imports.py
|
||||
const library = getLibrary(name);
|
||||
if (!library) return false;
|
||||
|
||||
let functions;
|
||||
if (SYMBOLS.length) {
|
||||
// https://github.com/hyugogirubato/KeyDive/issues/13#issuecomment-2143741896
|
||||
functions = SYMBOLS.map(s => ({
|
||||
type: s.type,
|
||||
name: s.name,
|
||||
address: library.base.add(s.address)
|
||||
}));
|
||||
} else {
|
||||
functions = getFunctions(library);
|
||||
}
|
||||
|
||||
functions = functions.filter(f => !NATIVE_C_API.includes(f.name));
|
||||
const targets = functions.filter(f => OEM_CRYPTO_API.includes(f.name)).map(f => f.name);
|
||||
let hooked = 0;
|
||||
|
||||
functions.forEach(func => {
|
||||
if (func.type !== 'function') return;
|
||||
const {name: funcName, address: funcAddr} = func;
|
||||
|
||||
try {
|
||||
if (funcName.includes('UsePrivacyMode')) {
|
||||
UsePrivacyMode(funcAddr);
|
||||
} else if (funcName.includes('GetCdmClientPropertySet')) {
|
||||
GetCdmClientPropertySet(funcAddr);
|
||||
} else if (funcName.includes('PrepareKeyRequest')) {
|
||||
PrepareKeyRequest(funcAddr);
|
||||
} else if (funcName.includes('getOemcryptoDeviceId')) {
|
||||
GetDeviceId(funcAddr);
|
||||
} else if (targets.includes(funcName) || (!targets.length && funcName.match(/^[a-z]+$/))) {
|
||||
GetCertificatePrivateKey(funcAddr, funcName);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
hooked++;
|
||||
print(Level.DEBUG, `Hooked (${funcAddr}): ${funcName}`);
|
||||
} catch (e) {
|
||||
print(Level.ERROR, `${e.message} for ${funcName}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (hooked < 3) {
|
||||
print(Level.CRITICAL, 'Insufficient functions hooked.');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// RPC interfaces exposed to external calls.
|
||||
rpc.exports = {
|
||||
getlibrary: getLibrary,
|
||||
hooklibrary: hookLibrary
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
class Vendor:
|
||||
"""
|
||||
Represents a Vendor with OEM, version, and name attributes.
|
||||
"""
|
||||
|
||||
def __init__(self, oem: int, version: str, name: str):
|
||||
"""
|
||||
Initializes a Vendor instance.
|
||||
|
||||
Args:
|
||||
oem (int): The OEM identifier.
|
||||
version (str): The version of the vendor.
|
||||
name (str): The name of the vendor.
|
||||
"""
|
||||
self.oem = oem
|
||||
self.version = version
|
||||
self.name = name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""
|
||||
Returns a string representation of the Vendor instance.
|
||||
|
||||
Returns:
|
||||
str: String representation of the Vendor instance.
|
||||
"""
|
||||
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