release v3.0.0

This commit is contained in:
hyugogirubato
2025-06-09 18:12:49 +02:00
parent 2f74b4c5b7
commit 82d6a903dd
28 changed files with 5384 additions and 1620 deletions
+10 -6
View File
@@ -1,3 +1,6 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -170,10 +173,11 @@ poetry.toml
# LSP config files
pyrightconfig.json
### KeyDive ###
*.xml
# End of https://www.toptal.com/developers/gitignore/api/python
# KeyDive
device/
logs/
/docs/server/curl.txt
/docs/server/provisioning.json
/docs/debug.js
docs/pdf/
docs/protos/
docs/scripts/
*.proto
+62
View File
@@ -4,6 +4,67 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.0.0] - 2025-06-09
### Added
- Support for OTA provisioning.
- Dumping of OEM Device Certificate to allow manual L3 provisioning without a keybox.
- Dynamic keybox generation.
- Detection of keybox token during provisioning (including L1 support when `device_aes_key` is provided).
- New challenge interception function (TODO: may reduce dump failures?).
- Option `--no-stop` to keep capture running after requirement is met.
- Debug display of DRM player PID.
- Debug detection and display of default browser PID (supports Google Chrome, Samsung Internet, Mozilla Firefox).
- Display of client capabilities in debug mode.
- Full JSON-formatted output for client information.
- New private function hooks.
- Support and backward compatibility for Frida API 17+.
- Ctrl+C support when analyzing the device (ADB shell command).
- New contributor: [samu87d8dh2](https://github.com/samu87d8dh2)
### Changed
- All C API functions are now filtered.
- Standardized JS hook functions.
- The keybox is now handled as an object rather than a separate process.
- DRM information parsing (keybox, device ID, challenge, token, etc.) is now centralized in a single class.
- Constants are now split per module instead of being centralized in a single file.
- Widevine license protobuf updated to 2020 version (partially compatible with CDM 19+).
- `cryptography` is now used instead of `pycryptodomex`.
- CDM is now resolved with improved accuracy (security level, system ID).
- Keybox level is now validated against the SDK.
- Standardized Frida JS script file reading functions.
- Clearer output for `-a player` or `-a web` options.
- Deprecated script message is now shown only once, at the first hook.
- Data export now occurs after every relevant event (optimization).
- CDM search is performed in descending version order.
- Index for extracting `client_id` argument has been adjusted.
- File names in generated tree are now normalized using `unidecode`.
### Fixed
- Process name resolution for Widevine DRM process.
- Missing hook on file read function.
- Vendor model updated to support library checking via regex and fix rendering.
- Updated function allowlist for Ghidra-based function analysis.
- ADB process listing fix (handles multiple entries with same name but different PIDs).
- `dumpsys` check for application package verification.
- DRM player app is no longer relaunched if already running (even in background).
- Frida server version is retrieved and displayed only once.
- Keybox is fully parsed only when decrypted data is available.
- CRC32 check added for keybox validation.
- Improved display when encrypted keybox is received (no more invalid output).
- Regex fix in process analysis.
- Proper handling of `getprop` output to conform with expected format.
- Removed dependency on `pywidevine` and unnecessary associated libraries.
- Option to force plaintext challenge added (disabled by default; encrypted interception now works).
- Better resolution of CDM level and security parameters.
### New Contributors
- [samu87d8dh2](https://github.com/samu87d8dh2)
## [2.2.1] - 2025-03-01
### Added
@@ -446,6 +507,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
- Initial release of the project, laying the foundation for future enhancements and features.
[3.0.0]: https://github.com/hyugogirubato/KeyDive/releases/tag/v3.0.0
[2.2.1]: https://github.com/hyugogirubato/KeyDive/releases/tag/v2.2.1
[2.2.0]: https://github.com/hyugogirubato/KeyDive/releases/tag/v2.2.0
[2.1.5]: https://github.com/hyugogirubato/KeyDive/releases/tag/v2.1.5
+92
View File
@@ -0,0 +1,92 @@
# KeyDive Developer Documentation
This document explains the internal workings of KeyDive, including key concepts in the JS hook scripts, the meaning of critical fields, and the flow of the extraction process.
## Key Concepts & Terminology
| Key | Description |
|-----------------------|-------------------------------------------------------------------------------------------|
| `provisioning_method` | Indicates the method used to provision Widevine DRM. |
| `challenge` | Unencrypted challenge data sent to Widevine CDM to trigger provisioning or key requests. |
| `client_id` | Unique identifier extracted from the device representing the Widevine client credentials. |
| `private_key` | RSA private key used for DRM content decryption and authentication. |
| `keybox` | Device-specific key storage blob containing keys for DRM operations. |
| `certificate` | Device OEM certificate chain for verifying keys and trust. |
## Script Execution Flow
The core extraction logic is implemented in a Frida hook script injected into Widevine CDM processes. The following sequence diagram illustrates the general flow, including options for player choice and output formats:
````
+----------------+ +------------------+ +---------------------+ +------------------+
| Start Script | | Select Player | | Provisioning Method | | Extraction Steps |
+----------------+ +------------------+ +---------------------+ +------------------+
| | | |
|--(option: -a player)-->| Launch Kaltura app | |
| | | |
|--(option: -a web)----->| Launch Bitmovin player | |
| | | |
| | |---Check provisioning_method-->|
| | | |
| | |<--Send challenge data---------|
| | | |
| | |---Extract client_id---------->|
| | |---Extract private_key-------->|
| | |---Extract keybox (optional)-->|
| | | |
| | |---Export data to output dir-->|
| | | |
| | | |
V V V V
(Process ends) (Process ends) (Process ends) (Process ends)
````
## Key Data Handling
### Provisioning Method
The provisioning method determines how the device is authenticated with Widevine. Internally, KeyDive uses the following enum:
| Enum Value | Meaning |
|---------------------|-----------------------------------------------------------------------------|
| `ProvisioningError` | Device cannot be provisioned (typically unsupported or failed setup). |
| `DrmCertificate` | The device uses a pre-installed baked-in DRM certificate (usually L3 only). |
| `Keybox` | A factory-installed keybox is used to identify the device and decrypt keys. |
| `OEMCertificate` | A factory-provisioned OEM certificate chain is present for full L1 support. |
These values are automatically detected at runtime to determine the correct extraction strategy.
### Challenge Handling
The `challenge` is a critical blob of data sent during provisioning or license acquisition. KeyDive can:
- Intercept it during DRM playback.
- Accept an external file via the `--challenge` argument.
This challenge contains the encrypted request for keys or certificates and is essential for key extraction workflows.
### Client ID & Private Key
Both the `client_id` and `private_key` are extracted from the intercepted CDM functions. They are:
- Required for device emulation or license response reconstruction.
- Automatically exported to formats like `client_id.bin`, `private_key.pem`, and `.wvd`.
## Advanced Options & Debugging
The advanced command-line options allow:
- Overriding automatic provisioning detection.
- Injecting Ghidra-extracted symbol maps via `--symbols`.
- Forcing use of unencrypted challenges (`--unencrypt`) for debugging or fallback workflows.
- Extracting optional blobs like the Widevine keybox or certificates.
These are primarily useful for development, debugging, or supporting less common device models.
## Contribution Guidelines
Feel free to contribute by submitting PRs or reporting issues. Please follow the coding style and provide tests for new features.
---
© hyugogirubato 2025
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 hyugogirubato
Copyright (c) 2025 hyugogirubato
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+59 -55
View File
@@ -1,27 +1,26 @@
# KeyDive: Widevine L3 Extractor for Android
# KeyDive: Widevine L3 Key Extractor for Android
KeyDive is a sophisticated Python script designed for precise extraction of Widevine L3 DRM (Digital Rights Management) keys from Android devices. This tool leverages the capabilities of the Widevine CDM (Content Decryption Module) to facilitate the recovery of DRM keys, enabling a deeper understanding and analysis of the Widevine L3 DRM implementation across various Android SDK versions.
KeyDive is a Python tool designed to extract Widevine L3 DRM keys from Android devices seamlessly, supporting multiple Android versions for DRM research, education, and analysis.
> [!IMPORTANT]
> A minimum version of `frida-server 16.6.0` is required for dynamic dumps on OEM API 18+ (SDK > 33). Otherwise, extracted functions from Ghidra are required.
> For dynamic key extraction on devices with Android SDK > 33 (OEM API 18+), a minimum `frida-server 16.6.0` is required. Otherwise, pre-extracted functions from Ghidra are necessary.
## Features
- 🚀 Seamless Installation via [pip](#installation)
- 🔄 Automated extraction of Widevine L3 DRM keys
- 📱 Compatibility with a wide range of Android versions (SDK > 21), ensuring broad applicability
- 💾 Seamless extraction process, yielding essential DRM components
- 🌐 Offline extraction mode for environments without internet access
- 🖥️ Command-line options for flexible usage
- 🛠️ Support for custom functions extracted from Widevine libraries using Ghidra
- ❤️ Fully Open-Source! Pull Requests Welcome
- 🚀 Easy installation with [pip](https://pip.pypa.io/)
- 🔄 Automated Widevine L3 key extraction supporting SDK > 21
- 📱 Supports a wide range of Android devices and versions
- 💾 Export extracted keys and device credentials in multiple formats including pywidevine `.wvd`
- 🌐 Offline extraction mode available
- 🖥️ Flexible command-line interface with multiple customization options
- 🛠️ Supports injecting custom Widevine functions extracted from Ghidra XML files
- ❤️ Fully open-source and actively maintained
## Prerequisites
Before you begin, ensure you have the following prerequisites in place:
1. **ADB (Android Debug Bridge):** Make sure to install [ADB](https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge) and include it in your system's PATH environment variable for easy command-line access.
2. **Frida-Server:** Install `frida-server` on your target Android device. This requires root access on the device. For installation instructions and downloads, visit the [official Frida documentation](https://frida.re/docs/installation/).
- **ADB (Android Debug Bridge):** Make sure to install [ADB](https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge) and include it in your system's PATH environment variable for easy command-line access.
- **frida-server:** Install `frida-server` on your target Android device. This requires root access on the device. For installation instructions and downloads, visit the [official Frida documentation](https://frida.re/docs/installation/).
- **Python 3.8+**
## Installation
@@ -29,18 +28,16 @@ Follow these steps to set up KeyDive:
1. Ensure all prerequisites are met (see above).
2. Install KeyDive directly from PyPI:
```shell
````shell
pip install keydive
```
````
## Usage
KeyDive enables secure extraction of Widevine L3 keys in a straightforward sequence:
1. Run the KeyDive script:
```bash
keydive -kwp
```
````bash
keydive -kw -a player
````
2. The script will install and launch the [Kaltura](https://github.com/kaltura/kaltura-device-info-android) DRM test app (if not already installed).
3. Follow these steps within the app:
- **Provision Widevine** (if the device isn't provisioned).
@@ -50,60 +47,66 @@ KeyDive enables secure extraction of Widevine L3 keys in a straightforward seque
- `client_id.bin` (device identification data).
- `private_key.pem` (RSA private key).
This sequence ensures that the DRM-protected content is active and ready for key extraction by the time the KeyDive script is initiated, optimizing the extraction process.
This will automatically install and launch the recommended DRM test app (Kaltura), provision Widevine if necessary, and perform the extraction steps.
> [!TIP]
> This sequence ensures that the DRM-protected content is active and ready for key extraction by the time the KeyDive script is initiated, optimizing the extraction process.
### Command-Line Options
```shell
usage: keydive [-h] [-d <id>] [-v] [-l <dir>] [--delay <delay>] [--version] [-o <dir>] [-w] [-s] [-a] [-p] [-f <file>] [-k] [--challenge <file>] [--private-key <file>]
````shell
Usage: keydive [-h] [-s <serial>] [-d <delay>] [-v] [-l <dir>] [-V] [-o <dir>] [-w] [-k] [-a <type>] [--no-detect] [--no-disabler] [--no-stop] [--unencrypt] [--symbols <symbols>] [--challenge <challenge>] [--rsa-key <rsa-key>] [--aes-key <aes-key>]
Extract Widevine L3 keys from an Android device.
Extract Widevine CDM components from an Android device.
options:
Optional Arguments:
-h, --help show this help message and exit
Global:
-d <id>, --device <id>
Specify the target Android device ID for ADB connection.
-v, --verbose Enable verbose logging for detailed debug output.
-l <dir>, --log <dir>
Directory to store log files.
--delay <delay> Delay (in seconds) between process checks.
--version Display KeyDive version information.
Global Options:
-s, --serial <serial>
ADB serial number of the target Android device.
-d, --delay <delay> Delay in seconds between process status checks. (default: 1.0)
-v, --verbose Enable detailed logging for debugging.
-l, --log <dir> Directory to save log files.
-V, --version Show tool version and exit.
Cdm:
-o <dir>, --output <dir>
Output directory for extracted data.
-w, --wvd Generate a pywidevine WVD device file.
-s, --skip Skip auto-detection of the private function.
-a, --auto Automatically start the Bitmovin web player.
-p, --player Install and start the Kaltura app automatically.
Cdm Extraction:
-o, --output <dir> Directory to store extracted CDM files. (default: ./device)
-w, --wvd Export data in pywidevine-compatible WVD format.
-k, --keybox Export Widevine keybox if available on the device.
-a, --auto <type> Automatically launch a DRM playback test. ("web" or "player")
Advanced:
-f <file>, --functions <file>
Path to Ghidra XML functions file.
-k, --keybox Enable export of the Keybox data if it is available.
--challenge <file> Path to unencrypted challenge for extracting client ID.
--private-key <file> Path to private key for extracting client ID.
Advanced Options:
--no-detect Disable automatic detection of OEM private key function.
--no-disabler Disable liboemcrypto-disabler module (patches memory protection).
--no-stop Do not stop once minimum CDM data is intercepted.
--unencrypt Force the license challenge to keep client ID data unencrypted.
--symbols <symbols> Path to Ghidra-generated XML symbol file for function mapping.
--challenge <challenge>
Protobuf challenge file(s) captured via MITM proxy.
--rsa-key <rsa-key> RSA private key(s) in PEM or DER format for client ID decryption.
--aes-key <aes-key> AES key(s) in hex, base64, or file form for decrypting keybox data.
````
```
> [!NOTE]
> The advanced options are primarily intended for debugging and development purposes. Regular users do not need to use them.
## Advanced Usage
### Extracting Functions
For advanced users looking to use custom functions with KeyDive, a comprehensive guide on extracting functions from Widevine libraries using Ghidra is available. Please refer to our [Functions Extraction Guide](https://github.com/hyugogirubato/KeyDive/blob/main/docs/advanced/FUNCTIONS.md) for detailed instructions.
Custom functions extracted from Widevine libraries with Ghidra can be provided to KeyDive to improve compatibility on some devices. See the [Functions Extraction Guide](https://github.com/hyugogirubato/KeyDive/blob/main/docs/advanced/FUNCTIONS.md).
### Offline Extraction
KeyDive supports offline extraction mode for situations without internet access. This mode allows you to extract DRM keys directly from your Android device. Ensure all necessary dependencies are installed and follow the detailed [Offline Mode Guide](https://github.com/hyugogirubato/KeyDive/blob/main/docs/advanced/OFFLINE.md) for step-by-step instructions.
KeyDive supports offline extraction workflows suitable for restricted environments. See the [Offline Mode Guide](https://github.com/hyugogirubato/KeyDive/blob/main/docs/advanced/OFFLINE.md).
### Obtaining Unencrypted Challenge Data
### Using Unencrypted Challenge Data
> [!NOTE]
> Usage of unencrypted challenge is not required by default. It is only necessary when the script cannot extract the client id.
> [!CAUTION]
> The `--unencrypt` option forces the license challenge to keep client ID data unencrypted. This option can cause repetitive crashes or instability in the Widevine library on certain devices.
To extract the unencrypted challenge data required for KeyDive's advanced features, follow the steps outlined in our [Challenge Extraction Guide](https://github.com/hyugogirubato/KeyDive/blob/main/docs/advanced/CHALLENGE.md). This data is crucial for analyzing DRM-protected content and enhancing your DRM key extraction capabilities.
When client ID extraction fails, provide an unencrypted challenge via `--challenge`. See the [Challenge Extraction Guide](https://github.com/hyugogirubato/KeyDive/blob/main/docs/advanced/CHALLENGE.md).
### Temporary Disabling L1 for L3 Extraction
@@ -124,6 +127,7 @@ KeyDive is intended for educational and research purposes only. The use of this
<a href="https://github.com/JohnDoe1964"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/167800584?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="JohnDoe1964"/></a>
<a href="https://github.com/Nineteen93"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/107993263?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="Nineteen93"/></a>
<a href="https://github.com/sn-o-w"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/2406819?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="sn-o-w"/></a>
<a href="https://github.com/sn-o-w"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/206893953?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt="samu87d8dh2"/></a>
## Licensing
+1 -1
View File
@@ -56,7 +56,7 @@ To extract unencrypted challenges using KeyDive and HTTP Toolkit, you can follow
6. **Use Extracted Data:**
- Use the extracted data with KeyDive by running:
```shell
keydive --device <DEVICE_ID> --challenge path/to/challenge
keydive --serial <DEVICE_ID> --challenge path/to/challenge
```
Replace `path/to/challenge` with the actual path to the extracted challenge data file.
+3 -3
View File
@@ -9,7 +9,7 @@ To utilize custom functions with KeyDive, particularly when extracting Widevine
Run KeyDive to detect the library path on the Android device:
```shell
keydive --device <DEVICE_ID>
keydive --serial <DEVICE_ID>
```
Replace `<DEVICE_ID>` with the ID of your Android device connected via ADB.
@@ -77,9 +77,9 @@ Ensure you have Ghidra installed on your system. If not, download it from the [G
Once you have the `functions.xml` file:
- Ensure KeyDive is set up according to its documentation.
- When running KeyDive, use the `--functions` argument to specify the path to your `functions.xml` file. For example:
- When running KeyDive, use the `--symbols` argument to specify the path to your `functions.xml` file. For example:
```shell
keydive --device <DEVICE_ID> --functions /path/to/functions_x86.xml
keydive --serial <DEVICE_ID> --symbols /path/to/functions_x86.xml
```
- Proceed with the key extraction process as detailed in KeyDive's usage instructions.
+56 -53
View File
@@ -1,25 +1,26 @@
import argparse
import json
import time
import logging
from argparse import ArgumentParser
from pathlib import Path
import requests
from flask import Flask, Response, request, redirect
from rich_argparse import RichHelpFormatter
from keydive.__main__ import configure_logging
from keydive.utils import configure_logging
# Suppress urllib3 warnings
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR)
# Initialize Flask application
app = Flask(__name__)
# Define paths, constants, and global flags
PARENT = Path(__file__).parent
VERSION = "1.0.1"
VERSION = '1.0.2'
KEYBOX = False
DELAY = 10
@@ -30,9 +31,9 @@ def health_check() -> Response:
Health check endpoint to confirm the server is running.
Returns:
Response: A simple "pong" message with a 200 OK status.
Response: A simple 'pong' message with a 200 OK status.
"""
return Response(response="pong", status=200, content_type="text/html; charset=utf-8")
return Response(response='pong', status=200, content_type='text/html; charset=utf-8')
@app.route('/shaka-demo-assets/angel-one-widevine/<path:file>', methods=['GET'])
@@ -41,17 +42,17 @@ def shaka_demo_assets(file) -> Response:
Serves cached assets for Widevine demo content. If the requested file is
not available locally, it fetches it from a remote server and caches it.
Parameters:
Args:
file (str): File path requested by the client.
Returns:
Response: File content as a byte stream, or a 404 error if not found.
"""
logger = logging.getLogger("Shaka")
logger.info("%s %s", request.method, request.path)
logger = logging.getLogger('Shaka')
logger.info('%s %s', request.method, request.path)
try:
path = PARENT / ".assets" / file
path = PARENT / '.assets' / file
path.parent.mkdir(parents=True, exist_ok=True)
if path.is_file():
@@ -60,20 +61,20 @@ def shaka_demo_assets(file) -> Response:
else:
# Fetch the file from remote storage if not cached locally
r = requests.get(
url=f"https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine/{file}",
url=f'https://storage.googleapis.com/shaka-demo-assets/angel-one-widevine/{file}',
headers={
"Accept": "*/*",
"User-Agent": "KalturaDeviceInfo/1.4.1 (Linux;Android 10) ExoPlayerLib/2.9.3"
'Accept': '*/*',
'User-Agent': 'KalturaDeviceInfo/1.4.1 (Linux;Android 10) ExoPlayerLib/2.9.3'
}
)
r.raise_for_status()
path.write_bytes(r.content) # Cache the downloaded content
content = r.content
logger.debug("Downloaded assets: %s", path)
logger.debug('Downloaded assets: %s', path)
return Response(response=content, status=200, content_type="application/octet-stream")
return Response(response=content, status=200, content_type='application/octet-stream')
except Exception as e:
return Response(response=str(e), status=404, content_type="text/html; charset=utf-8")
return Response(response=str(e), status=404, content_type='text/html; charset=utf-8')
@app.route('/certificateprovisioning/v1/devicecertificates/create', methods=['POST'])
@@ -87,16 +88,16 @@ def certificate_provisioning() -> Response:
Response: JSON response if provisioning is complete, else a redirection.
"""
global KEYBOX, DELAY
logger = logging.getLogger("Google")
logger.info("%s %s", request.method, request.path)
logger = logging.getLogger('Google')
logger.info('%s %s', request.method, request.path)
if KEYBOX:
logger.warning("Provisioning request aborted to prevent keybox spam")
return Response(response="Internal Server Error", status=500, content_type="text/html; charset=utf-8")
logger.warning('Provisioning request aborted to prevent keybox spam')
return Response(response='Internal Server Error', status=500, content_type='text/html; charset=utf-8')
# Generate a curl command from the incoming request for debugging or testing
user_agent = request.headers.get("User-Agent", "Unknown")
url = request.url.replace("http://", "https://")
user_agent = request.headers.get('User-Agent', 'Unknown')
url = request.url.replace('http://', 'https://')
prompt = [
'curl',
'--request', 'POST',
@@ -109,15 +110,15 @@ def certificate_provisioning() -> Response:
]
# Save the curl command for potential replay or inspection
curl = PARENT / "curl.txt"
curl.write_text(" \\\n ".join(prompt))
logger.debug("Saved curl command to: %s", curl)
curl = PARENT / 'curl.txt'
curl.write_text(' \\\n '.join(prompt))
logger.debug('Saved curl command to: %s', curl)
# Wait for provisioning response data with retries
logger.warning("Waiting for provisioning response...")
provision = PARENT / "provisioning.json"
logger.warning('Waiting for provisioning response...')
provision = PARENT / 'provisioning.json'
provision.unlink(missing_ok=True)
provision.write_bytes(b"") # Create empty file for manual input if needed
provision.write_bytes(b'') # Create empty file for manual input if needed
# Poll for the presence of a response up to DELAY times with 1-second intervals
for _ in range(DELAY):
@@ -127,13 +128,13 @@ def certificate_provisioning() -> Response:
# Cleanup after successful response
curl.unlink(missing_ok=True)
provision.unlink(missing_ok=True)
return Response(response=content, status=200, content_type="application/json")
return Response(response=content, status=200, content_type='application/json')
except Exception as e:
pass # Continue waiting if file is empty or not yet ready
time.sleep(1)
# Redirect to the secure URL if response is not available
logger.warning("Redirecting to avoid timeout")
logger.warning('Redirecting to avoid timeout')
return redirect(url, code=302)
@@ -143,32 +144,32 @@ def main() -> None:
to set global parameters and configures logging, then starts the Flask server.
"""
global VERSION, DELAY, KEYBOX
parser = argparse.ArgumentParser(description="Local DRM provisioning video player.")
parser = ArgumentParser(
description='Local DRM provisioning video player.',
formatter_class=RichHelpFormatter)
# Global arguments for the application
group_global = parser.add_argument_group("Global")
group_global.add_argument('--host', required=False, type=str, default="127.0.0.1", metavar="<host>", help="Host address for the server to bind to.")
group_global.add_argument('--port', required=False, type=int, default=9090, metavar="<port>", help="Port number for the server to listen on.")
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('--version', required=False, action="store_true", help="Display Server version information.")
global_group = parser.add_argument_group('Global Options')
global_group.add_argument('--host', type=str, default='127.0.0.1', metavar='<host>', help='Host address for the server to bind to.')
global_group.add_argument('--port', type=int, default=9090, metavar='<port>', help='Port number for the server to listen on.')
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('--version', action='store_true', help='Show tool version and exit.')
# Advanced options
group_advanced = parser.add_argument_group("Advanced")
group_advanced.add_argument('-d', '--delay', required=False, type=int, metavar="<delay>", default=10, help="Delay (in seconds) between successive checks for provisioning responses.")
group_advanced.add_argument('-k', '--keybox', required=False, action="store_true", help="Enable keybox mode, which aborts provisioning requests to prevent spam.")
advanced_group = parser.add_argument_group('Advanced Options')
advanced_group.add_argument('-d', '--delay', type=int, metavar='<delay>', default=10, help='Delay (in seconds) between successive checks for provisioning responses.')
advanced_group.add_argument('-k', '--keybox', action='store_true', help='Enable keybox mode, which aborts provisioning requests to prevent spam.')
args = parser.parse_args()
# Handle version display
# Handle version flag early and exit
if args.version:
print(f"Server {VERSION}")
exit(0)
print(f'Server {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("Server")
logger.info("Version: %s", VERSION)
logger = logging.getLogger('Server')
logger.info('Version: %s', VERSION)
try:
# Set global variables based on parsed arguments
@@ -176,18 +177,20 @@ def main() -> None:
KEYBOX = args.keybox
# Start Flask app with specified host, port, and debug mode
logging.getLogger("werkzeug").setLevel(logging.INFO if args.verbose else logging.ERROR)
logging.getLogger('werkzeug').setLevel(logging.INFO if args.verbose else logging.ERROR)
app.run(host=args.host, port=args.port, debug=False)
except KeyboardInterrupt:
# Graceful exit on user interrupt (Ctrl+C)
pass
except Exception as e:
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()
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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",)
+171
View File
@@ -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'
+371
View File
@@ -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',)
+114
View File
@@ -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
View File
@@ -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",)
-145
View File
@@ -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"
}
+393 -155
View File
@@ -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:
class Server:
def __init__(self, version: str):
"""
Core class for managing DRM operations and interactions with Android devices.
Initialize the Server instance with the provided Frida version string.
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('Server')
def __init__(self, adb: ADB, cdm: Cdm, functions: Path = None, skip: bool = False):
self.version = version
self.logger.debug('Frida version: %s', self.version)
# 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)
# 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
):
"""
Initializes a Core instance.
Initialize the Core object, which manages interaction with a remote device
and handles Widevine CDM (Content Decryption Module) processes.
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 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.
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.running = True
self.cdm = cdm
self.adb = adb
# Initialize base Remote object with optional serial and connection timeout
super().__init__(serial=serial, timeout=timeout)
self.logger = logging.getLogger('Core')
# Flag to skip predefined functions based on the vendor's API level
# https://github.com/hyugogirubato/KeyDive/issues/38#issuecomment-2411932679
self.skip = skip
# Indicates whether the watchdog process is actively running
self._running = False
# 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")
# Server instance (initialized later when Frida is attached)
self._server: Optional[Server] = None
def __prepare_hook_script(self) -> str:
# 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 the hook script by injecting library-specific data.
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',)
+55
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+113
View File
@@ -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')
+243
View File
@@ -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 120124
LEVEL_TAG = b'LVL1' # Optional level tag used by QSEE environments (byte offset 128132)
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
-180
View File
@@ -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 48120)
"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",)
+560 -193
View File
@@ -1,5 +1,5 @@
/**
* Date: 2025-03-01
* Date: 2025-06-09
* Description: DRM key extraction for research and educational purposes.
* Source: https://github.com/hyugogirubato/KeyDive
*/
@@ -8,7 +8,9 @@
const OEM_CRYPTO_API = JSON.parse('${OEM_CRYPTO_API}');
const NATIVE_C_API = JSON.parse('${NATIVE_C_API}');
const SYMBOLS = JSON.parse('${SYMBOLS}');
const SKIP = '${SKIP}' === 'True';
const DETECT = '${DETECT}' === 'True';
const DISABLER = '${DISABLER}' === 'True';
const UNENCRYPT = '${UNENCRYPT}' === 'True';
// Logging levels to synchronize with Python's logging module.
@@ -23,12 +25,71 @@ const Level = {
CRITICAL: 50
};
// Utility for encoding strings into byte arrays (UTF-8).
// https://gist.github.com/Yaffle/5458286#file-textencodertextdecoder-js
function TextEncoder() {
// Backward compatibility with the modern equivalent implemented since frida 17
// https://frida.re/news/2025/05/17/frida-17-0-0-released/
/*
readS8 = readShort
readU8 = readUShort
readS16 = readInt
readU16 = readUInt
readS32 = readFloat
readU32 = readDouble
console.log(hexdump(address, {
offset: 0,
length: 128,
header: true,
ansi: true
}));
*/
Memory = typeof Memory === 'undefined' ? {} : Memory;
Memory.readByteArray ??= (address, length) => address.readByteArray(length);
Memory.readPointer ??= (address) => address.readPointer();
Memory.readU16 ??= (address) => address.readU16();
Memory.readStdString = function (address) {
// https://learnfrida.info/intermediate_usage/#stdstring
// Read string size (2 bytes) at offset pointerSize
const size = Memory.readU16(address.add(Process.pointerSize));
// Check if string is using Small String Optimization (SSO)
const LSB = address.readU8() & 1;
if (LSB === 0) {
// https://codeshare.frida.re/@oleavr/read-std-string/
// SSO: data is stored inline starting at address + 1
return Memory.readByteArray(address.add(1), size);
} else {
// Non-SSO: pointer to data is stored at address
return Memory.readByteArray(address.add(Process.pointerSize * 2).readPointer(), size);
}
}
TextEncoder.prototype.encode = function (string) {
Memory.readStdVector = function (address) {
// https://learnfrida.info/intermediate_usage/#stdvector
// Read the vector size (2 bytes) at offset pointerSize
let size = Memory.readU16(address.add(Process.pointerSize));
// Read pointer to the start of the vector data (at offset 0)
const data = Memory.readByteArray(address.readPointer(), size);
/*
const buffer = new Uint8Array(data);
// Trim trailing null bytes (0x00) from the end
while (size > 0 && buffer[size - 1] === 0x00) {
size--;
}
// Return the trimmed buffer
return buffer.slice(0, size);
*/
return data;
}
// Utility for encoding strings into byte arrays (UTF-8).
// https://gist.github.com/Yaffle/5458286#file-textencodertextdecoder-js
class TextEncoder {
encode(string) {
const octets = [];
let i = 0;
while (i < string.length) {
@@ -56,7 +117,8 @@ TextEncoder.prototype.encode = function (string) {
i += codePoint >= 0x10000 ? 2 : 1;
}
return octets;
};
}
}
// Simplified log function to handle messages and encode them for transport.
const print = (level, message) => {
@@ -69,7 +131,7 @@ const getVersion = () => Frida.version;
// @Utils
const getLibraries = () => {
function getLibraries() {
// https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146788792
try {
return Process.enumerateModules();
@@ -79,55 +141,94 @@ const getLibraries = () => {
}
}
const getLibrary = (name) => {
const libraries = getLibraries().filter(l => l.name === name);
return libraries.length === 1 ? libraries[0] : undefined;
function getLibrary(name) {
return getLibraries().find(l => l.name === name);
}
const getFunctions = (library, dynamic) => {
function getFunctions(library, dynamic = false) {
try {
// https://frida.re/news/2025/01/09/frida-16-6-0-released/
const functions = dynamic ? library.enumerateSymbols().map(item => ({
const functions = dynamic
? library.enumerateSymbols().map(item => ({
type: item.type,
name: item.name,
address: item.address
address:
item.address
})) : [];
library.enumerateExports().forEach(item => {
if (!functions.includes(item)) {
functions.push(item);
}
});
return functions;
return functions.concat(library.enumerateExports());
} catch (e) {
print(Level.CRITICAL, e.message);
return [];
}
}
const disableLibrary = (name) => {
function getDerLength(buffer) {
let pos = 1; // Skip the tag byte (usually 0x30 for SEQUENCE)
let lengthByte = buffer[pos++]; // Read length descriptor
if (lengthByte < 0x80) {
// Short form: length is in this byte
return pos + lengthByte;
}
// Long form: next N bytes encode the length
const numLenBytes = lengthByte & 0x7F;
if (numLenBytes + pos > buffer.length) {
throw new Error('DER length bytes exceed buffer size');
}
let lengthValue = 0;
for (let i = 0; i < numLenBytes; i++) {
lengthValue = (lengthValue << 8) + buffer[pos++]; // Accumulate length
}
// Total length = current pos (after length bytes) + value length
return pos + lengthValue;
}
function readDerKey(address, size) {
// Read initial bytes from memory
const data = Memory.readByteArray(address, size);
const buffer = new Uint8Array(data);
const tag = buffer[0]; // Usually 0x30 for SEQUENCE
// Check if tag indicates a DER SEQUENCE
if (tag !== 0x30) return;
try {
// Adjust size based on DER length field
size = getDerLength(buffer); // ASN.1 DER
} catch (e) {
// Ignore error, keep initial size
}
// Return the DER key slice
return buffer.slice(0, size);
}
function disableLibrary(name) {
// Disables all functions in the specified library by replacing their implementations.
const library = getLibrary(name);
if (library) {
// https://github.com/hyugogirubato/KeyDive/issues/23#issuecomment-2230374415
const functions = getFunctions(library, false);
const disabled = [];
const disabled = new Set();
functions.forEach(func => {
const {name: funcName, address: funcAddr} = func;
if (func.type !== 'function' || disabled.includes(funcAddr)) return;
functions.forEach(({name: funcName, address: funcAddr, type}) => {
if (type !== 'function' || disabled.has(funcAddr)) return;
try {
Interceptor.replace(funcAddr, new NativeCallback(function () {
return 0;
}, 'int', []));
disabled.push(funcAddr);
Interceptor.replace(funcAddr, new NativeCallback(() => ptr(0), 'pointer', []));
disabled.add(funcAddr);
} catch (e) {
print(Level.DEBUG, `${e.message} for ${funcName}`);
}
});
print(Level.INFO, `Library ${library.name} (${library.path}) has been disabled`);
} else {
print(Level.DEBUG, `Library ${name} was not found`);
@@ -136,259 +237,491 @@ const disableLibrary = (name) => {
// @Libraries
const UsePrivacyMode = (address) => {
function Properties_UsePrivacyMode(address) {
/*
wvcdm::Properties::UsePrivacyMode
Args:
args[0]: std::string const&
args[1]: const CdmSessionId& session_id
Return:
retval: bool
*/
Interceptor.replace(address, new NativeCallback(function () {
return 0;
}, 'int', []));
Interceptor.replace(address, new NativeCallback(() => 0, 'bool', []));
Interceptor.attach(address, {
onEnter: function (args) {
print(Level.DEBUG, '[+] onEnter: UsePrivacyMode');
print(Level.DEBUG, '[+] onEnter: Properties::UsePrivacyMode');
},
onLeave: function (retval) {
print(Level.DEBUG, '[-] onLeave: UsePrivacyMode');
print(Level.DEBUG, '[-] onLeave: Properties::UsePrivacyMode');
}
});
}
const GetCdmClientPropertySet = (address) => {
function Properties_GetCdmClientPropertySet(address) {
/*
wvcdm::Properties::GetCdmClientPropertySet
Args:
args[0]: std::string const&
args[1]: const CdmSessionId& session_id
Return:
retval: wvcdm::CdmClientPropertySet*
*/
Interceptor.replace(address, new NativeCallback(function () {
return 0;
}, 'int', []));
Interceptor.replace(address, new NativeCallback(() => ptr(0), 'pointer', []));
Interceptor.attach(address, {
onEnter: function (args) {
print(Level.DEBUG, '[+] onEnter: GetCdmClientPropertySet');
print(Level.DEBUG, '[+] onEnter: Properties::GetCdmClientPropertySet');
},
onLeave: function (retval) {
print(Level.DEBUG, '[-] onLeave: GetCdmClientPropertySet');
print(Level.DEBUG, '[-] onLeave: Properties::GetCdmClientPropertySet');
}
});
}
const PrepareKeyRequest = (address) => {
function CdmLicense_PrepareKeyRequest(address) {
/*
wvcdm::CdmLicense::PrepareKeyRequest
Args:
args[0]: wvcdm::CdmLicense *this
args[1]: wvcdm::InitializationData const&
args[2]: wvcdm::CdmLicenseType
args[3]: std::map<std::string
args[4]: std::string> const&
args[5]: std::string*
args[6]: std::string*
args[1]: const InitializationData& init_data
args[2]: const std::string& client_token
args[3]: CdmLicenseType license_type
args[4]: const CdmAppParameterMap& app_parameters
args[5]: CdmKeyMessage* signed_request
args[6]: std::string* server_url
Return:
retval: wvcdm::CdmResponseType
*/
Interceptor.attach(address, {
onEnter: function (args) {
print(Level.DEBUG, '[+] onEnter: PrepareKeyRequest');
print(Level.DEBUG, '[+] onEnter: CdmLicense::PrepareKeyRequest');
// https://github.com/hyugogirubato/KeyDive/issues/13#issue-2327487249
this.params = [];
for (let i = 0; i < 7; i++) {
for (let i = 0; i < 8; i++) {
this.params.push(args[i]);
}
},
onLeave: function (retval) {
print(Level.DEBUG, '[-] onLeave: PrepareKeyRequest');
print(Level.DEBUG, '[-] onLeave: CdmLicense::PrepareKeyRequest');
let dumped = false;
// Extract and dump the relevant arguments
for (let i = 0; i < this.params.length; i++) {
// Extract the signed_request data (CdmKeyMessage*)
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) {
const signedRequestData = Memory.readStdString(this.params[i]);
if (signedRequestData) {
dumped = true;
send('challenge', data);
send('challenge', signedRequestData);
}
} catch (e) {
// print(Level.WARNING, `Failed to dump data for arg ${i}`);
// print(Level.WARNING, `Failed to extract signed_request data from args[${i}]`);
}
}
!dumped && print(Level.ERROR, 'Failed to dump challenge');
!dumped && print(Level.ERROR, 'Failed to dump challenge data');
}
});
}
const LoadDeviceRSAKey = (address, name) => {
// wvdash::OEMCrypto::LoadDeviceRSAKey
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, `[*] LoadDeviceRSAKey: ${name}`);
send({'private_key': name}, key);
}
}
}
},
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.
function CdmEngine_GenerateKeyRequest(address) {
/*
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::_oecc07
wvcdm::CdmEngine::GenerateKeyRequest
Args:
args[0]: uchar *
args[1]: ulong *
args[3]: wvcdm::SecurityLevel
args[1]: const CdmSessionId& session_id
args[2]: const CdmKeySetId& key_set_id
args[3]: const InitializationData& init_data
args[4]: const CdmLicenseType license_type
args[5]: CdmAppParameterMap& app_parameters
args[6]: CdmKeyRequest* key_request
Return:
retval: wvcdm::CdmResponseType
*/
Interceptor.attach(address, {
onEnter: function (args) {
// print(Level.DEBUG, '[+] onEnter: GetDeviceId');
this.data = args[0];
this.size = args[1];
print(Level.DEBUG, '[+] onEnter: CdmEngine::GenerateKeyRequest');
// https://github.com/hyugogirubato/KeyDive/issues/13#issue-2327487249
this.params = [];
for (let i = 0; i < 8; i++) {
this.params.push(args[i]);
}
},
onLeave: function (retval) {
// print(Level.DEBUG, '[-] onLeave: GetDeviceId');
const size = Memory.readPointer(this.size).toInt32();
const data = Memory.readByteArray(this.data, size);
print(Level.DEBUG, '[-] onLeave: CdmEngine::GenerateKeyRequest');
let dumped = false;
if (data) {
print(Level.DEBUG, `[*] GetDeviceId: ${name}`);
send('device_id', data);
// Extract and dump the relevant arguments
for (let i = 0; i < this.params.length; i++) {
// Extract the signed_request data (CdmKeyMessage*)
try {
const signedRequestData = Memory.readStdString(this.params[i]);
if (signedRequestData) {
dumped = true;
send('challenge', signedRequestData);
}
} catch (e) {
// print(Level.WARNING, `Failed to extract signed_request data from args[${i}]`);
}
}
!dumped && print(Level.ERROR, 'Failed to dump challenge data');
}
});
}
function AesCbcKey_Encrypt(address) {
/*
wvcdm::AesCbcKey::Encrypt
Args:
args[1]: const std::string& in
args[2]: std::string* out
args[3]: std::string* iv
Return:
retval: bool
*/
Interceptor.attach(address, {
onEnter: function (args) {
const inData = Memory.readStdString(args[1]);
if (inData) {
print(Level.DEBUG, '[*] AesCbcKey::Encrypt');
send('client_id', inData);
}
}
});
}
const FileSystemRead = (address) => {
function FileSystem_Read(address) {
/*
wvoec3::OEMCrypto_Level3AndroidFileSystem::Read
Args:
args[0]: wvoec3::OEMCrypto_Level3AndroidFileSystem *this
args[1]: char const*
args[2]: void *
args[3]: ulong
args[1]: const char *filename
args[2]: void *buffer
args[3]: size_t size
Return:
retval: ssize_t
*/
Interceptor.attach(address, {
onEnter: function (args) {
// print(Level.DEBUG, '[+] onEnter: FileSystemRead');
const bufferPtr = args[2];
const size = args[3].toInt32();
const data = Memory.readByteArray(args[2], size);
const data = Memory.readByteArray(bufferPtr, size);
// Check if the size matches known keybox sizes (128 or 132 bytes)
if ([128, 132].includes(size) && data) {
print(Level.DEBUG, '[*] FileSystemRead');
print(Level.DEBUG, '[*] FileSystem::Read');
send('keybox', data);
}
},
onLeave: function (retval) {
// print(Level.DEBUG, '[-] onLeave: FileSystemRead');
}
});
}
const FileRead = (address, name) => {
function File_Read(address) {
/*
wvcdm::File::Read
Args:
args[0]: wvcdm::File *this
args[1]: char *
args[2]: uint
args[1]: char* buffer
args[2]: size_t bytes
Return:
retval: ssize_t
*/
/*
_x1c36
Args:
args[0]: char *filename
args[1]: void *ptr
args[2]: size_t n
args[0]: std::string* filename
args[1]: char* buffer
args[2]: size_t bytes
Return:
retval: ssize_t
*/
Interceptor.attach(address, {
onEnter: function (args) {
// print(Level.DEBUG, `[+] onEnter: FileRead: ${name}`);
const bufferPtr = args[1];
const size = args[2].toInt32();
const data = Memory.readByteArray(args[1], size);
const data = Memory.readByteArray(bufferPtr, size);
// Check if the size matches known keybox sizes (128 or 132 bytes)
if ([128, 132].includes(size) && data) {
print(Level.DEBUG, `[*] FileRead: ${name}`);
print(Level.DEBUG, '[*] File::Read');
send('keybox', data);
}
},
onLeave: function (retval) {
// print(Level.DEBUG, `[-] onLeave: FileRead: ${name}`);
}
});
}
const RunningCRC = (address) => {
function RunningCRC(address) {
/*
wvrunningcrc32
wvoec::wvrunningcrc32
Args:
args[0]: uchar const*
args[1]: int
args[2]: uint
args[0]: const uint8_t* p_begin
args[1]: int i_count
args[2]: uint32_t i_crc
Return:
retval: uint32_t
*/
Interceptor.attach(address, {
onEnter: function (args) {
// print(Level.DEBUG, '[+] onEnter: RunningCRC');
const size = args[1].toInt32();
const data = Memory.readByteArray(args[0], 128);
// Check if size matches keybox length excluding 4-byte magic/tag fields
if (size === 124) {
const data = Memory.readByteArray(args[0], 128);
if (size === 124 && data) {
print(Level.DEBUG, '[*] RunningCRC');
send('keybox', data);
}
}
});
}
function OEMCrypto_GetDeviceID(address) {
/*
wvcdm::OEMCrypto_GetDeviceID
Args:
args[0]: uint8_t* deviceID
args[1]: size_t* idLength
args[2]: SecurityLevel level
Return:
retval: OEMCryptoResult
*/
Interceptor.attach(address, {
onEnter: function (args) {
this.deviceIdPtr = args[0];
this.idLengthPtr = args[1];
},
onLeave: function (retval) {
// print(Level.DEBUG, '[-] onLeave: RunningCRC');
const idLength = Memory.readPointer(this.idLengthPtr).toInt32();
const deviceIdData = Memory.readByteArray(this.deviceIdPtr, idLength);
if (deviceIdData) {
print(Level.DEBUG, '[*] OEMCrypto_GetDeviceID');
send('stable_id', deviceIdData);
}
}
});
}
function OEMCrypto_GetKeyData(address) {
/*
wvcdm::OEMCrypto_GetKeyData
Args:
args[0]: uint8_t* keyData
args[1]: size_t* keyDataLength
args[2]: SecurityLevel level
Return:
retval: OEMCryptoResult
*/
Interceptor.attach(address, {
onEnter: function (args) {
this.keyDataPtr = args[0];
this.keyDataLengthPtr = args[1];
},
onLeave: function (retval) {
const keyDataLength = Memory.readPointer(this.keyDataLengthPtr).toInt32();
const keyData = Memory.readByteArray(this.keyDataPtr, keyDataLength);
if (keyData) {
print(Level.DEBUG, '[*] OEMCrypto_GetKeyData');
send('device_id', keyData);
}
}
});
}
function OEMCrypto_ProvisioningMethod(address) {
/*
wvcdm::OEMCrypto_GetProvisioningMethod
Args:
args[0]: SecurityLevel level
Return:
retval: OEMCrypto_ProvisioningMethod
*/
Interceptor.attach(address, {
onLeave: function (retval) {
// https://github.com/fox0618/dumper/blob/main/Helpers/script.js#L784
print(Level.DEBUG, '[*] OEMCrypto_ProvisioningMethod');
send('provisioning_method', new TextEncoder().encode(`${retval.toInt32()}`));
}
});
}
function OEMCrypto_GenerateDerivedKeys(address) {
/*
wvcdm::OEMCrypto_GenerateDerivedKeys
Args:
args[0]: OEMCrypto_SESSION session
args[1]: const uint8_t* mac_key_context
args[2]: uint32_t mac_key_context_length
args[3]: const uint8_t* enc_key_context,
args[4]: uint32_t enc_key_context_length
Return:
retval: OEMCryptoResult
*/
Interceptor.attach(address, {
onEnter: function (args) {
print(Level.DEBUG, '[+] onEnter: OEMCrypto_GenerateDerivedKeys');
// https://github.com/Avalonswanderer/widevinel3_Android_PoC/blob/main/PoCs/content_key_recovery.py#L103C55-L103C72
const macKeyContext = Memory.readByteArray(args[1], args[2].toInt32());
const encKeyContext = Memory.readByteArray(args[3], args[4].toInt32());
// Used for L1 provisioning
// console.log('macKeyContext:', macKeyContext);
/*
macKeyContext: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
00000000 41 55 54 48 45 4e 54 49 43 41 54 49 4f 4e 00 12 AUTHENTICATION..
00000010 04 0a d5 8e c0 1a 04 08 00 12 00 2a 98 06 0a 0c ...........*....
00000020 77 69 64 65 76 69 6e 65 2e 63 6f 6d 12 10 51 43 widevine.com..QC
00000030 4f e2 a4 4c 76 3b cc 2c 82 6a 2d 6e f9 a7 1a e0 O..Lv;.,.j-n....
*/
// console.log('encKeyContext:', encKeyContext);
/*
encKeyContext: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
00000000 45 4e 43 52 59 50 54 49 4f 4e 00 12 04 0a d5 8e ENCRYPTION......
00000010 c0 1a 04 08 00 12 00 2a 98 06 0a 0c 77 69 64 65 .......*....wide
00000020 76 69 6e 65 2e 63 6f 6d 12 10 51 43 4f e2 a4 4c vine.com..QCO..L
00000030 76 3b cc 2c 82 6a 2d 6e f9 a7 1a e0 03 ec 2a c5 v;.,.j-n......*.
*/
},
onLeave: function (retval) {
print(Level.DEBUG, '[-] onLeave: OEMCrypto_GenerateDerivedKeys');
}
});
}
function OEMCrypto_DeriveKeysFromSessionKey(address) {
/*
wvcdm::OEMCrypto_DeriveKeysFromSessionKey
Args:
args[0]: OEMCrypto_SESSION session
args[1]: const uint8_t* enc_session_key
args[2]: size_t enc_session_key_length
args[3]: const uint8_t* mac_key_context
args[4]: size_t mac_key_context_length
args[5]: const uint8_t* enc_key_context
args[6]: size_t enc_key_context_length
Return:
retval: OEMCryptoResult
*/
Interceptor.attach(address, {
onEnter: function (args) {
print(Level.DEBUG, '[+] onEnter: OEMCrypto_DeriveKeysFromSessionKey');
const encSessionKey = Memory.readByteArray(args[1], args[2].toInt32());
const macKeyContext = Memory.readByteArray(args[3], args[4].toInt32());
const encKeyContext = Memory.readByteArray(args[5], args[6].toInt32());
// Used for L1 license request
// console.log('encSessionKey:', encSessionKey);
/*
encSessionKey: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
00000000 c4 9d ef cf 5f 0b 98 9c 03 46 93 89 14 8f 08 e2 ...._....F......
00000010 12 da 13 39 ad 31 75 f7 b5 32 94 ee 2f 7f bf 6a ...9.1u..2../..j
00000020 d7 45 c0 50 22 9a 6c 36 76 a7 78 d8 9f 76 b5 45 .E.P".l6v.x..v.E
00000030 f3 5c 6f 25 91 08 cf de a3 d9 90 08 cb e1 d4 55 .\o%...........U
*/
// console.log('macKeyContext:', macKeyContext);
/*
macKeyContext: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
00000000 41 55 54 48 45 4e 54 49 43 41 54 49 4f 4e 00 0a AUTHENTICATION..
00000010 c3 0f 08 01 12 aa 0b 0a ed 03 08 02 12 20 8d a4 ............. ..
00000020 21 77 04 fb 58 ff d6 58 80 8c d2 32 b5 81 01 5a !w..X..X...2...Z
00000030 6a d6 97 29 97 51 ac 92 95 de 81 fe b3 13 18 f7 j..).Q..........
*/
// console.log('encKeyContext:', encKeyContext);
/*
encKeyContext: 0 1 2 3 4 5 6 7 8 9 A B C D E F 0123456789ABCDEF
00000000 45 4e 43 52 59 50 54 49 4f 4e 00 0a c3 0f 08 01 ENCRYPTION......
00000010 12 aa 0b 0a ed 03 08 02 12 20 8d a4 21 77 04 fb ......... ..!w..
00000020 58 ff d6 58 80 8c d2 32 b5 81 01 5a 6a d6 97 29 X..X...2...Zj..)
00000030 97 51 ac 92 95 de 81 fe b3 13 18 f7 f6 ad c1 06 .Q..............
*/
},
onLeave: function (retval) {
print(Level.DEBUG, '[-] onLeave: OEMCrypto_DeriveKeysFromSessionKey');
}
});
}
function WVDrmPlugin_provideProvisionResponse(address) {
/*
wvdrm::WVDrmPlugin::provideProvisionResponse
Args:
args[1]: const Vector<uint8_t>& response
args[2]: Vector<uint8_t>& certificate,
args[3]: Vector<uint8_t>& wrapped_key
Return:
retval: wvcdm::CdmResponseType
*/
Interceptor.attach(address, {
onEnter: function (args) {
print(Level.DEBUG, '[*] WVDrmPlugin::provideProvisionResponse');
let dumped = false;
// Extract and dump the relevant arguments
for (let i = 0; i < 4; i++) {
try {
const responseData = Memory.readStdVector(args[i]);
if (responseData) {
dumped = true;
send('provisioning_response', responseData);
}
} catch (e) {
// print(Level.WARNING, `Failed to extract provisioning response data from args[${i}]`);
}
}
!dumped && print(Level.ERROR, 'Failed to dump provisioning response data');
}
});
}
function Level3_RewrapDeviceRSAKey30(address, name) {
/*
wvcdm::Level3_RewrapDeviceRSAKey30
Args:
args[1]: OEMCrypto_SESSION session
args[2]: const uint32_t* nonce
args[3]: const uint8_t* encrypted_message_key
args[4]: size_t encrypted_message_key_length
args[5]: const uint8_t* enc_rsa_key
args[6]: size_t enc_rsa_key_length
args[7]: const uint8_t* enc_rsa_key_iv
args[8]: uint8_t* wrapped_rsa_key
args[9]: size_t* wrapped_rsa_key_length
Return:
retval: OEMCryptoResult
*/
Interceptor.attach(address, {
onEnter: function (args) {
const bufferPtr = args[5];
const sizePtr = args[6];
if (!(sizePtr.isNull() || bufferPtr.isNull())) {
// Check if this is a pointer to a buffer
if (bufferPtr < 0x10000) return;
// Check if the size matches a potential 2048 or 4096-bit RSA private key (range 1190-2350)
const size = sizePtr.toInt32();
if (size < 1000 || size > 2400) return;
const bufferData = readDerKey(bufferPtr, size);
if (bufferData) {
print(Level.DEBUG, `[*] Level3_RewrapDeviceRSAKey30: ${name}`);
send({'private_key': name}, bufferData);
}
}
}
});
}
@@ -401,12 +734,12 @@ const hookLibrary = (name, dynamic) => {
if (!library) return false;
let functions;
if (SYMBOLS.length) {
if (Object.keys(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)
functions = Object.entries(SYMBOLS).map(([key, value]) => ({
type: 'function',
name: value,
address: library.base.add(ptr(key))
}));
} else {
// https://github.com/hyugogirubato/KeyDive/issues/50
@@ -414,55 +747,89 @@ const hookLibrary = (name, dynamic) => {
}
functions = functions.filter(f => !NATIVE_C_API.includes(f.name));
let targets = SKIP ? [] : functions.filter(f => OEM_CRYPTO_API.includes(f.name)).map(f => f.name);
const hooked = [];
let targets = DETECT ? functions.filter(f => OEM_CRYPTO_API.includes(f.name)).map(f => f.name) : [];
functions.forEach(func => {
let required = false;
const {name: funcName, address: funcAddr} = func;
if (func.type !== 'function' || hooked.includes(funcAddr)) return;
const required = new Set();
const hooked = new Set();
functions.forEach(({name: funcName, address: funcAddr, type}) => {
if (type !== 'function' || required.has(funcAddr)) return;
try {
if (funcName.includes('UsePrivacyMode')) {
UsePrivacyMode(funcAddr);
required = true;
} else if (funcName.includes('GetCdmClientPropertySet')) {
GetCdmClientPropertySet(funcAddr);
required = true;
} else if (funcName.includes('PrepareKeyRequest')) {
PrepareKeyRequest(funcAddr);
required = true;
} else if (targets.includes(funcName) || (!targets.length && funcName.match(/^[a-z]+$/))) {
LoadDeviceRSAKey(funcAddr, funcName);
required = true;
} else if (['lcc07', 'oecc07', 'getOemcryptoDeviceId'].some(n => funcName.includes(n))) {
GetDeviceId(funcAddr, funcName);
// Interception of client ID via challenge or in clear text
if (['AesCbcKey', 'Encrypt'].every(n => funcName.includes(n))) {
AesCbcKey_Encrypt(funcAddr);
required.add(funcAddr);
// Calling this method is further down in the challenge request execution flow
// Using the GenerateKeyRequest function at a higher level
//} else if (['CdmLicense', 'PrepareKeyRequest'].every(n => funcName.includes(n))) {
// CdmLicense_PrepareKeyRequest(funcAddr);
// required.add(funcAddr);
} else if (['CdmEngine', 'GenerateKeyRequest'].every(n => funcName.includes(n))) {
CdmEngine_GenerateKeyRequest(funcAddr);
required.add(funcAddr);
// Full and block keybox interception
} else if (['FileSystem', 'Read'].every(n => funcName.includes(n))) {
FileSystemRead(funcAddr);
} else if (['File', 'Read'].every(n => funcName.includes(n)) || funcName.includes('x1c36')) {
FileRead(funcAddr, funcName);
} else if (funcName.includes('runningcrc')) {
FileSystem_Read(funcAddr);
} else if (['File', 'Read'].every(n => funcName.includes(n)) || funcName.includes('_x1c36')) {
File_Read(funcAddr);
} else if (['runningcrc'].every(n => funcName.includes(n))) {
// https://github.com/Avalonswanderer/widevinel3_Android_PoC/blob/main/PoCs/recover_l3keybox.py#L50
RunningCRC(funcAddr);
} else if (['_oecc07', '_lcc07'].some(n => funcName.includes(n))) {
OEMCrypto_GetDeviceID(funcAddr);
} else if (['_oecc04', '_lcc04'].some(n => funcName.includes(n))) {
OEMCrypto_GetKeyData(funcAddr);
// TODO: Check the keybox implementation on SDK 36
// TODO: Interception of the certificate's private key
// Call OEMCrypto_GetOEMPublicCertificate before OEMCrypto_LoadDRMPrivateKey
// Provisioning Interception
} else if (['_oecc49', '_lcc49'].some(n => funcName.includes(n))) {
OEMCrypto_ProvisioningMethod(funcAddr);
// Key derivation via keybox for L1 provisioning
//} else if (['_oecc12', '_lcc12', '_oecc95', '_lcc95'].some(n => funcName.includes(n))) {
// OEMCrypto_GenerateDerivedKeys(funcAddr);
// Key derivation via session key for L1 license request
//} else if (['_oecc21', '_lcc21'].some(n => funcName.includes(n))) {
// OEMCrypto_DeriveKeysFromSessionKey(funcAddr);
} else if (['WVDrmPlugin', 'provideProvisionResponse'].every(n => funcName.includes(n))) {
WVDrmPlugin_provideProvisionResponse(funcAddr);
// Disable encrypted client id for license request (deprecated)
} else if (UNENCRYPT && ['Properties', 'GetCdmClientPropertySet'].every(n => funcName.includes(n))) {
Properties_GetCdmClientPropertySet(funcAddr);
} else if (UNENCRYPT && ['Properties', 'UsePrivacyMode'].every(n => funcName.includes(n))) {
// Calling this function usually returns a boolean handled by the GetCdmClientPropertySet subcall
// Replacing this function is quite unstable, causing library crashes
Properties_UsePrivacyMode(funcAddr);
// OEM private key interruption from obfuscated functions
} else if (targets.includes(funcName) || (!targets.length && funcName.match(/^[a-z]+$/))) {
Level3_RewrapDeviceRSAKey30(funcAddr, funcName);
required.add(funcAddr);
} else {
return;
}
required && hooked.push(funcAddr);
hooked.add(funcAddr);
print(Level.DEBUG, `Hooked (${funcAddr}): ${funcName}`);
} catch (e) {
print(Level.ERROR, `${e.message} for ${funcName}`);
}
});
if (hooked.length < 3) {
if (required.size < 3) {
print(Level.CRITICAL, 'Insufficient functions hooked');
return false;
}
if (DISABLER) {
// TODO: Disable old L1 libraries? (https://github.com/wvdumper/dumper/blob/main/Helpers/Scanner.py#L23)
// https://github.com/hzy132/liboemcryptodisabler/blob/master/customize.sh#L33
disableLibrary('liboemcrypto.so');
}
return true;
}
+213
View File
@@ -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
-34
View File
@@ -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",)
+22 -13
View File
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "keydive"
version = "2.2.1"
version = "3.0.0"
description = "Extract Widevine L3 keys from Android devices effortlessly, spanning multiple Android versions for DRM research and education."
license = "MIT"
authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"]
@@ -34,16 +34,27 @@ include = [
"Changelog" = "https://github.com/hyugogirubato/KeyDive/blob/main/CHANGELOG.md"
[tool.poetry.dependencies]
python = "^3.8"
coloredlogs = "^15.0.1"
frida = "^16.6.0"
pathlib = "^1.0.1"
pycryptodomex = "^3.21.0"
pywidevine = "^1.8.0"
pathvalidate = "^3.2.1"
requests = "^2.32.3"
xmltodict = "^0.14.2"
Flask = { version = "^3.0.3", optional = true }
python = ">=3.8,<4.0"
asn1crypto = ">=1.5.1"
coloredlogs = ">=15.0.1"
construct = "^2.10.70"
crccheck = ">=1.3.0"
cryptography = ">=45.0.3"
frida = ">=17.1.3"
pathvalidate = ">=3.2.1"
protobuf = "^5.29.5"
requests = ">=2.32.3"
rich-argparse = ">=1.7.1"
Unidecode = ">=1.4.0"
xmltodict = ">=0.14.2"
Flask = { version = ">=3.0.3", optional = true }
[tool.poetry.group.dev.dependencies]
frida-tools = ">=14.1.1"
mypy-protobuf = ">=3.6.0"
[tool.poetry.extras]
offline = ["Flask"]
[tool.poetry.scripts]
keydive = "keydive.__main__:main"
@@ -55,5 +66,3 @@ priority = "primary"
[certificates]
localpypi = { cert = false }