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