release v2.2.0
This commit is contained in:
parent
f0cff80f90
commit
bc50b55f5a
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -4,6 +4,23 @@ 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).
|
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).
|
||||||
|
|
||||||
|
## [2.2.0] - Not Released
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for dynamic interception without the need for Ghidra (available only for Frida server versions greater than 16.6.0).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Added additional comments to help understand the script.
|
||||||
|
- Optimized file path management in parameters.
|
||||||
|
- Refactored the code globally.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Inconsistency in logging messages with certain functions.
|
||||||
|
- Fixing the server-generated curl command.
|
||||||
|
|
||||||
## [2.1.5] - 2025-01-12
|
## [2.1.5] - 2025-01-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
@ -3,22 +3,23 @@ import json
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from flask import Flask, Response, request, redirect
|
from flask import Flask, Response, request, redirect
|
||||||
|
|
||||||
from keydive.__main__ import configure_logging
|
from keydive.__main__ import configure_logging
|
||||||
|
|
||||||
# Suppress urllib3 warnings
|
# Suppress urllib3 warnings
|
||||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR)
|
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
|
||||||
|
|
||||||
# Initialize Flask application
|
# Initialize Flask application
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
# Define paths, constants, and global flags
|
# Define paths, constants, and global flags
|
||||||
PARENT = Path(__file__).parent
|
PARENT = Path(__file__).parent
|
||||||
VERSION = '1.0.0'
|
VERSION = "1.0.1"
|
||||||
KEYBOX = False
|
KEYBOX = False
|
||||||
DELAY = 10
|
DELAY = 10
|
||||||
|
|
||||||
|
@ -31,7 +32,7 @@ def health_check() -> Response:
|
||||||
Returns:
|
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'])
|
@app.route('/shaka-demo-assets/angel-one-widevine/<path:file>', methods=['GET'])
|
||||||
|
@ -40,17 +41,17 @@ def shaka_demo_assets(file) -> Response:
|
||||||
Serves cached assets for Widevine demo content. If the requested file is
|
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.
|
not available locally, it fetches it from a remote server and caches it.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
file (str): File path requested by the client.
|
file (str): File path requested by the client.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: File content as a byte stream, or a 404 error if not found.
|
Response: File content as a byte stream, or a 404 error if not found.
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger('Shaka')
|
logger = logging.getLogger("Shaka")
|
||||||
logger.info('%s %s', request.method, request.path)
|
logger.info("%s %s", request.method, request.path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = PARENT / '.assets' / file
|
path = PARENT / ".assets" / file
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
|
@ -59,20 +60,20 @@ def shaka_demo_assets(file) -> Response:
|
||||||
else:
|
else:
|
||||||
# Fetch the file from remote storage if not cached locally
|
# Fetch the file from remote storage if not cached locally
|
||||||
r = requests.get(
|
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={
|
headers={
|
||||||
'Accept': '*/*',
|
"Accept": "*/*",
|
||||||
'User-Agent': 'KalturaDeviceInfo/1.4.1 (Linux;Android 10) ExoPlayerLib/2.9.3'
|
"User-Agent": "KalturaDeviceInfo/1.4.1 (Linux;Android 10) ExoPlayerLib/2.9.3"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
path.write_bytes(r.content) # Cache the downloaded content
|
path.write_bytes(r.content) # Cache the downloaded content
|
||||||
content = r.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:
|
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'])
|
@app.route('/certificateprovisioning/v1/devicecertificates/create', methods=['POST'])
|
||||||
|
@ -86,37 +87,37 @@ def certificate_provisioning() -> Response:
|
||||||
Response: JSON response if provisioning is complete, else a redirection.
|
Response: JSON response if provisioning is complete, else a redirection.
|
||||||
"""
|
"""
|
||||||
global KEYBOX, DELAY
|
global KEYBOX, DELAY
|
||||||
logger = logging.getLogger('Google')
|
logger = logging.getLogger("Google")
|
||||||
logger.info('%s %s', request.method, request.path)
|
logger.info("%s %s", request.method, request.path)
|
||||||
|
|
||||||
if KEYBOX:
|
if KEYBOX:
|
||||||
logger.warning('Provisioning request aborted to prevent keybox spam')
|
logger.warning("Provisioning request aborted to prevent keybox spam")
|
||||||
return Response(response='Internal Server Error', status=500, content_type='text/html; charset=utf-8')
|
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
|
# Generate a curl command from the incoming request for debugging or testing
|
||||||
user_agent = request.headers.get('User-Agent', 'Unknown')
|
user_agent = request.headers.get("User-Agent", "Unknown")
|
||||||
url = request.url.replace('http://', 'https://')
|
url = request.url.replace("http://", "https://")
|
||||||
prompt = [
|
prompt = [
|
||||||
"curl --request POST",
|
'curl',
|
||||||
f"--url '{url}'",
|
'--request', 'POST',
|
||||||
"--compressed",
|
'--compressed',
|
||||||
"--header 'accept-encoding: gzip'",
|
'--header', '"Accept-Encoding: gzip"',
|
||||||
"--header 'connection: Keep-Alive'",
|
'--header', '"Connection: Keep-Alive"',
|
||||||
"--header 'content-type: application/x-www-form-urlencoded'",
|
'--header', '"Content-Type: application/x-www-form-urlencoded"',
|
||||||
"--header 'host: www.googleapis.com'",
|
'--header', '"Host: www.googleapis.com"',
|
||||||
f"--header 'user-agent: {user_agent}'"
|
'--header', f'"User-Agent: {user_agent}"'
|
||||||
]
|
]
|
||||||
|
|
||||||
# Save the curl command for potential replay or inspection
|
# Save the curl command for potential replay or inspection
|
||||||
curl = PARENT / 'curl.txt'
|
curl = PARENT / "curl.txt"
|
||||||
curl.write_text(' \\\n '.join(prompt))
|
curl.write_text(" \\\n ".join(prompt))
|
||||||
logger.debug('Saved curl command to: %s', curl)
|
logger.debug("Saved curl command to: %s", curl)
|
||||||
|
|
||||||
# Wait for provisioning response data with retries
|
# Wait for provisioning response data with retries
|
||||||
logger.warning('Waiting for provisioning response...')
|
logger.warning("Waiting for provisioning response...")
|
||||||
provision = PARENT / 'provisioning.json'
|
provision = PARENT / "provisioning.json"
|
||||||
provision.unlink(missing_ok=True)
|
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
|
# Poll for the presence of a response up to DELAY times with 1-second intervals
|
||||||
for _ in range(DELAY):
|
for _ in range(DELAY):
|
||||||
|
@ -126,13 +127,13 @@ def certificate_provisioning() -> Response:
|
||||||
# Cleanup after successful response
|
# Cleanup after successful response
|
||||||
curl.unlink(missing_ok=True)
|
curl.unlink(missing_ok=True)
|
||||||
provision.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:
|
except Exception as e:
|
||||||
pass # Continue waiting if file is empty or not yet ready
|
pass # Continue waiting if file is empty or not yet ready
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Redirect to the secure URL if response is not available
|
# 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)
|
return redirect(url, code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@ -142,31 +143,32 @@ def main() -> None:
|
||||||
to set global parameters and configures logging, then starts the Flask server.
|
to set global parameters and configures logging, then starts the Flask server.
|
||||||
"""
|
"""
|
||||||
global VERSION, DELAY, KEYBOX
|
global VERSION, DELAY, KEYBOX
|
||||||
parser = argparse.ArgumentParser(description='Local DRM provisioning video player.')
|
parser = argparse.ArgumentParser(description="Local DRM provisioning video player.")
|
||||||
|
|
||||||
# Global arguments for the application
|
# Global arguments for the application
|
||||||
group_global = parser.add_argument_group('Global')
|
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('--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('--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('-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('-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.')
|
group_global.add_argument('--version', required=False, action="store_true", help="Display Server version information.")
|
||||||
|
|
||||||
# Advanced options
|
# Advanced options
|
||||||
group_advanced = parser.add_argument_group('Advanced')
|
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('-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.')
|
group_advanced.add_argument('-k', '--keybox', required=False, action="store_true", help="Enable keybox mode, which aborts provisioning requests to prevent spam.")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle version display
|
||||||
if args.version:
|
if args.version:
|
||||||
print(f'Server {VERSION}')
|
print(f"Server {VERSION}")
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging (file and console)
|
||||||
log_path = configure_logging(path=args.log, verbose=args.verbose)
|
log_path = configure_logging(path=args.log, verbose=args.verbose)
|
||||||
logger = logging.getLogger('Server')
|
logger = logging.getLogger("Server")
|
||||||
logger.info('Version: %s', VERSION)
|
logger.info("Version: %s", VERSION)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Set global variables based on parsed arguments
|
# Set global variables based on parsed arguments
|
||||||
|
@ -174,7 +176,7 @@ def main() -> None:
|
||||||
KEYBOX = args.keybox
|
KEYBOX = args.keybox
|
||||||
|
|
||||||
# Start Flask app with specified host, port, and debug mode
|
# 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)
|
app.run(host=args.host, port=args.port, debug=False)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
pass
|
pass
|
||||||
|
@ -183,9 +185,9 @@ def main() -> None:
|
||||||
|
|
||||||
# Final logging and exit
|
# Final logging and exit
|
||||||
if log_path:
|
if log_path:
|
||||||
logger.info('Log file: %s' % log_path)
|
logger.info("Log file: %s" % log_path)
|
||||||
logger.info('Exiting')
|
logger.info("Exiting")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
|
@ -4,4 +4,4 @@ from .cdm import Cdm
|
||||||
from .vendor import Vendor
|
from .vendor import Vendor
|
||||||
from .keybox import Keybox
|
from .keybox import Keybox
|
||||||
|
|
||||||
__version__ = '2.1.5'
|
__version__ = "2.2.0"
|
||||||
|
|
|
@ -4,6 +4,7 @@ import time
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
|
|
||||||
|
@ -15,16 +16,16 @@ from keydive.constants import CDM_VENDOR_API, DRM_PLAYER
|
||||||
from keydive.core import Core
|
from keydive.core import Core
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(path: Path = None, verbose: bool = False) -> Path:
|
def configure_logging(path: Path = None, verbose: bool = False) -> Optional[Path]:
|
||||||
"""
|
"""
|
||||||
Configures logging for the application.
|
Configures logging for the application.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
path (Path, optional): The directory to store log files.
|
path (Path, optional): The directory to store log files.
|
||||||
verbose (bool, optional): Flag to enable detailed debug logging.
|
verbose (bool, optional): Flag to enable detailed debug logging.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path: The path of log file.
|
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
|
# Set up the root logger with the desired logging level
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
|
@ -42,15 +43,15 @@ def configure_logging(path: Path = None, verbose: bool = False) -> Path:
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Create a file handler
|
# Create a file handler
|
||||||
file_path = path / ('keydive_%s.log' % datetime.now().strftime('%Y-%m-%d_%H-%M-%S'))
|
file_path = path / ("keydive_%s.log" % datetime.now().strftime("%Y-%m-%d_%H-%M-%S"))
|
||||||
file_path = file_path.resolve(strict=False)
|
file_path = file_path.resolve(strict=False)
|
||||||
file_handler = logging.FileHandler(file_path)
|
file_handler = logging.FileHandler(file_path)
|
||||||
file_handler.setLevel(logging.DEBUG)
|
file_handler.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
# Set log formatting
|
# Set log formatting
|
||||||
formatter = logging.Formatter(
|
formatter = logging.Formatter(
|
||||||
fmt='%(asctime)s [%(levelname).1s] %(name)s: %(message)s',
|
fmt="%(asctime)s [%(levelname).1s] %(name)s: %(message)s",
|
||||||
datefmt='%Y-%m-%d %H:%M:%S'
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
)
|
)
|
||||||
file_handler.setFormatter(formatter)
|
file_handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
@ -59,8 +60,8 @@ def configure_logging(path: Path = None, verbose: bool = False) -> Path:
|
||||||
|
|
||||||
# Configure coloredlogs for console output
|
# Configure coloredlogs for console output
|
||||||
coloredlogs.install(
|
coloredlogs.install(
|
||||||
fmt='%(asctime)s [%(levelname).1s] %(name)s: %(message)s',
|
fmt="%(asctime)s [%(levelname).1s] %(name)s: %(message)s",
|
||||||
datefmt='%Y-%m-%d %H:%M:%S',
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
level=logging.DEBUG if verbose else logging.INFO,
|
level=logging.DEBUG if verbose else logging.INFO,
|
||||||
logger=root_logger
|
logger=root_logger
|
||||||
)
|
)
|
||||||
|
@ -74,47 +75,48 @@ def main() -> None:
|
||||||
This application extracts Widevine L3 keys from an Android device.
|
This application extracts Widevine L3 keys from an Android device.
|
||||||
It supports device management via ADB and allows hooking into Widevine processes.
|
It supports device management via ADB and allows hooking into Widevine processes.
|
||||||
"""
|
"""
|
||||||
parser = argparse.ArgumentParser(description='Extract Widevine L3 keys from an Android device.')
|
parser = argparse.ArgumentParser(description="Extract Widevine L3 keys from an Android device.")
|
||||||
|
|
||||||
# Global arguments for the application
|
# Global arguments for the application
|
||||||
group_global = parser.add_argument_group('Global')
|
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('-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('-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('-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('--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.')
|
group_global.add_argument('--version', required=False, action="store_true", help="Display KeyDive version information.")
|
||||||
|
|
||||||
# Arguments specific to the CDM (Content Decryption Module)
|
# Arguments specific to the CDM (Content Decryption Module)
|
||||||
group_cdm = parser.add_argument_group('Cdm')
|
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('-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('-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('-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('-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.')
|
group_cdm.add_argument('-p', '--player', required=False, action="store_true", help="Install and start the Kaltura app automatically.")
|
||||||
|
|
||||||
# Advanced options
|
# Advanced options
|
||||||
group_advanced = parser.add_argument_group('Advanced')
|
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('-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('-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('--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.')
|
group_advanced.add_argument('--private-key', required=False, type=Path, metavar="<file>", help="Path to private key for extracting client ID.")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Handle version display
|
||||||
if args.version:
|
if args.version:
|
||||||
print(f'KeyDive {keydive.__version__}')
|
print(f"KeyDive {keydive.__version__}")
|
||||||
exit(0)
|
exit(0)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging (file and console)
|
||||||
log_path = configure_logging(path=args.log, verbose=args.verbose)
|
log_path = configure_logging(path=args.log, verbose=args.verbose)
|
||||||
logger = logging.getLogger('KeyDive')
|
logger = logging.getLogger("KeyDive")
|
||||||
logger.info('Version: %s', keydive.__version__)
|
logger.info("Version: %s", keydive.__version__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Connect to the specified Android device
|
# Connect to the specified Android device
|
||||||
adb = ADB(device=args.device)
|
adb = ADB(device=args.device)
|
||||||
|
|
||||||
# Initialize Cdm instance
|
# Initialize Cdm instance for content decryption module (with optional arguments)
|
||||||
cdm = Cdm(keybox=args.keybox)
|
cdm = Cdm(keybox=args.keybox)
|
||||||
if args.challenge:
|
if args.challenge:
|
||||||
cdm.set_challenge(data=args.challenge)
|
cdm.set_challenge(data=args.challenge)
|
||||||
|
@ -124,70 +126,70 @@ def main() -> None:
|
||||||
# Initialize Core instance for interacting with the device
|
# Initialize Core instance for interacting with the device
|
||||||
core = Core(adb=adb, cdm=cdm, functions=args.functions, skip=args.skip)
|
core = Core(adb=adb, cdm=cdm, functions=args.functions, skip=args.skip)
|
||||||
|
|
||||||
# Setup actions based on user arguments
|
# Setup actions based on user arguments (for DRM player, Bitmovin player, etc.)
|
||||||
if args.player:
|
if args.player:
|
||||||
package = DRM_PLAYER['package']
|
package = DRM_PLAYER["package"]
|
||||||
|
|
||||||
# Check if the application is already installed
|
# Check if the application is already installed
|
||||||
installed = package in adb.list_applications(user=True, system=False)
|
installed = package in adb.list_applications(user=True, system=False)
|
||||||
if not installed:
|
if not installed:
|
||||||
logger.debug('Application %s not found. Installing...', package)
|
logger.debug("Application %s not found. Installing...", package)
|
||||||
installed = adb.install_application(path=DRM_PLAYER['path'], url=DRM_PLAYER['url'])
|
installed = adb.install_application(path=DRM_PLAYER["path"], url=DRM_PLAYER["url"])
|
||||||
|
|
||||||
# Skip starting the application if installation failed
|
# Skip starting the application if installation failed
|
||||||
if installed:
|
if installed:
|
||||||
# Start the application
|
# Start the application
|
||||||
logger.info('Starting application: %s', package)
|
logger.info("Starting application: %s", package)
|
||||||
adb.start_application(package)
|
adb.start_application(package)
|
||||||
elif args.auto:
|
elif args.auto:
|
||||||
logger.info('Opening the Bitmovin web player...')
|
logger.info("Opening the Bitmovin web player...")
|
||||||
adb.open_url('https://bitmovin.com/demos/drm')
|
adb.open_url("https://bitmovin.com/demos/drm")
|
||||||
logger.info('Setup completed')
|
logger.info("Setup completed")
|
||||||
|
|
||||||
# Process watcher loop
|
# Process watcher loop: continuously checks for Widevine processes
|
||||||
logger.info('Watcher delay: %ss' % args.delay)
|
logger.info("Watcher delay: %ss" % args.delay)
|
||||||
current = None # Variable to track the current Widevine process
|
current = None # Variable to track the current Widevine process
|
||||||
while core.running:
|
while core.running:
|
||||||
# Check if for current process data has been exported
|
# Check if for current process data has been exported
|
||||||
if current and cdm.export(args.output, args.wvd):
|
if current and cdm.export(args.output, args.wvd):
|
||||||
raise KeyboardInterrupt # Stop if export is complete
|
raise KeyboardInterrupt # Stop if export is complete
|
||||||
|
|
||||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146788792
|
|
||||||
# Get the currently running Widevine processes
|
# Get the currently running Widevine processes
|
||||||
|
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146788792
|
||||||
processes = {
|
processes = {
|
||||||
key: (name, pid)
|
key: (name, pid)
|
||||||
for name, pid in adb.enumerate_processes().items()
|
for name, pid in adb.enumerate_processes().items()
|
||||||
for key in CDM_VENDOR_API.keys()
|
for key in CDM_VENDOR_API.keys()
|
||||||
if key in name or key.replace('-service', '-service-lazy') in name
|
if key in name or key.replace("-service", "-service-lazy") in name
|
||||||
}
|
}
|
||||||
|
|
||||||
if not processes:
|
if not processes:
|
||||||
raise EnvironmentError('Unable to detect Widevine, refer to https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#drm-info')
|
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
|
# Check if the current process has changed
|
||||||
if current and current not in [v[1] for v in processes.values()]:
|
if current and current not in [v[1] for v in processes.values()]:
|
||||||
logger.warning('Widevine process has changed')
|
logger.warning("Widevine process has changed")
|
||||||
current = None
|
current = None
|
||||||
|
|
||||||
# If current process not found, attempt to hook into the detected processes
|
# If current process not found, attempt to hook into the detected processes
|
||||||
if not current:
|
if not current:
|
||||||
logger.debug('Analysing...')
|
logger.debug("Analysing...")
|
||||||
|
|
||||||
for key, (name, pid) in processes.items():
|
for key, (name, pid) in processes.items():
|
||||||
if current:
|
if current:
|
||||||
break
|
break
|
||||||
for vendor in CDM_VENDOR_API[key]:
|
for vendor in CDM_VENDOR_API[key]:
|
||||||
if core.hook_process(pid=pid, vendor=vendor):
|
if core.hook_process(pid=pid, vendor=vendor):
|
||||||
logger.info('Process: %s (%s)', pid, name)
|
logger.info("Process: %s (%s)", pid, name)
|
||||||
current = pid
|
current = pid
|
||||||
break
|
break
|
||||||
elif not core.running:
|
elif not core.running:
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
if current:
|
if current:
|
||||||
logger.info('Successfully hooked')
|
logger.info("Successfully hooked")
|
||||||
else:
|
else:
|
||||||
logger.warning('Widevine library not found, searching...')
|
logger.warning("Widevine library not found, searching...")
|
||||||
|
|
||||||
# Delay before next iteration
|
# Delay before next iteration
|
||||||
time.sleep(args.delay)
|
time.sleep(args.delay)
|
||||||
|
@ -198,9 +200,9 @@ def main() -> None:
|
||||||
|
|
||||||
# Final logging and exit
|
# Final logging and exit
|
||||||
if log_path:
|
if log_path:
|
||||||
logger.info('Log file: %s' % log_path)
|
logger.info("Log file: %s" % log_path)
|
||||||
logger.info('Exiting')
|
logger.info("Exiting")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|
184
keydive/adb.py
184
keydive/adb.py
|
@ -3,29 +3,30 @@ import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import frida
|
import frida
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from frida.core import Device
|
from frida.core import Device
|
||||||
|
|
||||||
# Suppress urllib3 warnings
|
# Suppress urllib3 warnings
|
||||||
logging.getLogger('urllib3.connectionpool').setLevel(logging.ERROR)
|
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
|
||||||
def shell(prompt: list) -> subprocess.CompletedProcess:
|
def shell(prompt: list) -> subprocess.CompletedProcess:
|
||||||
"""
|
"""
|
||||||
Executes a shell command and returns the completed process.
|
Executes a shell command and returns the result.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
prompt (list): The command to be executed as a list of strings.
|
prompt (list): The command to execute as a list of strings.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
subprocess.CompletedProcess: The completed process object containing return code, stdout, and stderr.
|
subprocess.CompletedProcess: The result containing return code, stdout, and stderr.
|
||||||
"""
|
"""
|
||||||
prompt = list(map(str, prompt))
|
prompt = list(map(str, prompt)) # Ensure all command parts are strings
|
||||||
# logging.getLogger('Shell').debug(' '.join(prompt))
|
# logging.getLogger("Shell").debug(" ".join(prompt))
|
||||||
return subprocess.run(prompt, capture_output=True)
|
return subprocess.run(prompt, capture_output=True) # Run the command and capture output
|
||||||
|
|
||||||
|
|
||||||
class ADB:
|
class ADB:
|
||||||
|
@ -35,76 +36,76 @@ class ADB:
|
||||||
|
|
||||||
def __init__(self, device: str = None, timeout: int = 5):
|
def __init__(self, device: str = None, timeout: int = 5):
|
||||||
"""
|
"""
|
||||||
Initializes the ADB connection to a device.
|
Initializes ADB connection to the device.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
device (str, optional): The ID of the device to connect to. If None, defaults to the first USB device.
|
device (str, optional): Device ID to connect to, defaults to the first USB device.
|
||||||
timeout (int, optional): The timeout for device connection in seconds. Defaults to 5.
|
timeout (int, optional): Timeout for connection in seconds. Defaults to 5.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
EnvironmentError: If ADB is not found in the system path.
|
EnvironmentError: If ADB is not found in the system path.
|
||||||
Exception: If the connection to the device fails.
|
Exception: If connection to the device fails.
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger(self.__class__.__name__)
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
# Ensure ADB is available
|
# Ensure ADB is available
|
||||||
if not shutil.which('adb'):
|
if not shutil.which("adb"):
|
||||||
raise EnvironmentError(
|
raise EnvironmentError(
|
||||||
'ADB is not recognized as an environment variable. '
|
"ADB is not recognized as an environment variable. "
|
||||||
'Ensure ADB is installed and refer to the documentation: '
|
"Ensure ADB is installed and refer to the documentation: "
|
||||||
'https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge'
|
"https://github.com/hyugogirubato/KeyDive/blob/main/docs/PACKAGE.md#adb-android-debug-bridge"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Start the ADB server if not already running
|
# Start the ADB server if not already running
|
||||||
sp = shell(['adb', 'start-server'])
|
sp = shell(['adb', 'start-server'])
|
||||||
if sp.returncode != 0:
|
if sp.returncode != 0:
|
||||||
self.logger.warning('ADB server startup failed (Error: %s)', sp.stdout.decode('utf-8').strip())
|
self.logger.warning("ADB server startup failed (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||||
|
|
||||||
# Select device based on provided ID or default to the first USB device
|
# Connect to device (or default to the first USB device)
|
||||||
try:
|
try:
|
||||||
self.device: Device = frida.get_device(id=device, timeout=timeout) if device else frida.get_usb_device(timeout=timeout)
|
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)
|
self.logger.info("Connected to device: %s (%s)", self.device.name, self.device.id)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error('Failed to connect to device: %s', e)
|
self.logger.error("Failed to connect to device: %s", e)
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
self.prompt = ['adb', '-s', self.device.id, 'shell']
|
self.prompt = ['adb', '-s', self.device.id, 'shell']
|
||||||
|
|
||||||
# Obtain device properties
|
# Retrieve and log device properties
|
||||||
properties = self.device_properties()
|
properties = self.device_properties()
|
||||||
if properties:
|
if properties:
|
||||||
self.logger.info('SDK API: %s', properties.get('ro.build.version.sdk', 'Unknown'))
|
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'))
|
self.logger.info("ABI CPU: %s", properties.get("ro.product.cpu.abi", "Unknown"))
|
||||||
else:
|
else:
|
||||||
self.logger.warning('No device properties were retrieved.')
|
self.logger.warning("No device properties retrieved")
|
||||||
|
|
||||||
def device_properties(self) -> dict:
|
def device_properties(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Retrieves system properties from the connected device using ADB shell commands.
|
Retrieves system properties from the device.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary mapping device property keys to their corresponding values.
|
dict: A dictionary mapping property keys to their corresponding values.
|
||||||
"""
|
"""
|
||||||
# https://source.android.com/docs/core/architecture/configuration/add-system-properties?#shell-commands
|
# https://source.android.com/docs/core/architecture/configuration/add-system-properties?#shell-commands
|
||||||
properties = {}
|
properties = {}
|
||||||
|
|
||||||
# Execute shell command to retrieve device properties
|
# Execute the shell command to retrieve device properties
|
||||||
sp = shell([*self.prompt, 'getprop'])
|
sp = shell([*self.prompt, 'getprop'])
|
||||||
if sp.returncode != 0:
|
if sp.returncode != 0:
|
||||||
self.logger.error('Failed to retrieve device properties (Error: %s)', sp.stdout.decode('utf-8').strip())
|
self.logger.error("Failed to retrieve device properties (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||||
return properties
|
return properties
|
||||||
|
|
||||||
# Parse the output to fill the properties dictionary
|
# Parse the output and cast values accordingly
|
||||||
for line in sp.stdout.decode('utf-8').splitlines():
|
for line in sp.stdout.decode("utf-8").splitlines():
|
||||||
match = re.match(r'\[(.*?)\]: \[(.*?)\]', line)
|
match = re.match(r"\[(.*?)\]: \[(.*?)\]", line)
|
||||||
if match:
|
if match:
|
||||||
key, value = match.groups()
|
key, value = match.groups()
|
||||||
|
|
||||||
# Attempt to cast numeric and boolean values
|
# Cast numeric and boolean values where appropriate
|
||||||
if value.isdigit():
|
if value.isdigit():
|
||||||
value = int(value)
|
value = int(value)
|
||||||
elif value.lower() in ('true', 'false'):
|
elif value.lower() in ("true", "false"):
|
||||||
value = value.lower() == 'true'
|
value = value.lower() == "true"
|
||||||
|
|
||||||
properties[key] = value
|
properties[key] = value
|
||||||
|
|
||||||
|
@ -112,37 +113,38 @@ class ADB:
|
||||||
|
|
||||||
def list_applications(self, user: bool = True, system: bool = False) -> dict:
|
def list_applications(self, user: bool = True, system: bool = False) -> dict:
|
||||||
"""
|
"""
|
||||||
Lists installed applications on the device, filtering by user and/or system apps.
|
Lists installed applications on the device, with optional filters for user/system apps.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
user (bool, optional): Whether to include user-installed applications. Defaults to True.
|
user (bool, optional): Include user-installed apps. Defaults to True.
|
||||||
system (bool, optional): Whether to include system applications. Defaults to False.
|
system (bool, optional): Include system apps. Defaults to False.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary mapping application packages to their file paths.
|
dict: A dictionary of application packages and their file paths.
|
||||||
"""
|
"""
|
||||||
applications = {}
|
applications = {}
|
||||||
|
|
||||||
# Validate input and set the appropriate prompt
|
# Validate input; return empty dict if no filter is set
|
||||||
if not user and not system:
|
if not user and not system:
|
||||||
return applications
|
return applications
|
||||||
|
|
||||||
|
# Set the appropriate shell command based on user/system filters
|
||||||
prompt = [*self.prompt, 'pm', 'list', 'packages', '-f']
|
prompt = [*self.prompt, 'pm', 'list', 'packages', '-f']
|
||||||
if user and not system:
|
if user and not system:
|
||||||
prompt.append('-3')
|
prompt.append("-3")
|
||||||
elif not user and system:
|
elif not user and system:
|
||||||
prompt.append('-s')
|
prompt.append("-s")
|
||||||
|
|
||||||
# Execute shell command to list applications
|
# Execute the shell command to list applications
|
||||||
sp = shell(prompt)
|
sp = shell(prompt)
|
||||||
if sp.returncode != 0:
|
if sp.returncode != 0:
|
||||||
self.logger.error('Failed to retrieve application list (Error: %s)', sp.stdout.decode('utf-8').strip())
|
self.logger.error("Failed to retrieve app list (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||||
return applications
|
return applications
|
||||||
|
|
||||||
# Parse and store applications in the dictionary
|
# Parse and add applications to the dictionary
|
||||||
for line in sp.stdout.decode('utf-8').splitlines():
|
for line in sp.stdout.decode("utf-8").splitlines():
|
||||||
try:
|
try:
|
||||||
path, package = line.strip().split(':', 1)[1].rsplit('=', 1)
|
path, package = line.strip().split(":", 1)[1].rsplit("=", 1)
|
||||||
applications[package] = path
|
applications[package] = path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
@ -153,68 +155,68 @@ class ADB:
|
||||||
"""
|
"""
|
||||||
Starts an application by its package name.
|
Starts an application by its package name.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
package (str): The package name of the application to start.
|
package (str): The package name of the application.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the application was started successfully, False otherwise.
|
bool: True if the app was started successfully, False otherwise.
|
||||||
"""
|
"""
|
||||||
# Get package information
|
# Get package information using dumpsys
|
||||||
sp = shell([*self.prompt, 'dumpsys', 'package', package])
|
sp = shell([*self.prompt, 'dumpsys', 'package', package])
|
||||||
lines = sp.stdout.decode('utf-8').splitlines()
|
lines = sp.stdout.decode("utf-8").splitlines()
|
||||||
|
|
||||||
# Remove empty lines to ensure backwards compatibility
|
# Remove empty lines to ensure backwards compatibility
|
||||||
lines = [l.strip() for l in lines if l.strip()]
|
lines = [l.strip() for l in lines if l.strip()]
|
||||||
|
|
||||||
# Look for main activity in package information
|
# Look for MAIN activity to identify entry point
|
||||||
for i, line in enumerate(lines):
|
for i, line in enumerate(lines):
|
||||||
if 'android.intent.action.MAIN' in line:
|
if "android.intent.action.MAIN" in line:
|
||||||
match = re.search(fr'({package}/[^ ]+)', lines[i + 1])
|
match = re.search(fr"({package}/[^ ]+)", lines[i + 1])
|
||||||
if match:
|
if match:
|
||||||
# Attempt to start the application
|
# Start the application by its main activity
|
||||||
main_activity = match.group()
|
main_activity = match.group()
|
||||||
sp = shell([*self.prompt, 'am', 'start', '-n', main_activity])
|
sp = shell([*self.prompt, 'am', 'start', '-n', main_activity])
|
||||||
if sp.returncode == 0:
|
if sp.returncode == 0:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
self.logger.error('Failed to start application %s (Error: %s)', package, sp.stdout.decode('utf-8').strip())
|
self.logger.error("Failed to start app %s (Error: %s)", package, sp.stdout.decode("utf-8").strip())
|
||||||
break
|
break
|
||||||
|
|
||||||
self.logger.error('Package %s not found or has no MAIN intent action.', package)
|
self.logger.error("Package %s not found or no MAIN intent", package)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def enumerate_processes(self) -> dict:
|
def enumerate_processes(self) -> dict:
|
||||||
"""
|
"""
|
||||||
Lists running processes on the device, mapping process names to PIDs.
|
Lists running processes and maps process names to their PIDs.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary mapping process names to their corresponding PIDs.
|
dict: Dictionary of process names and corresponding PIDs.
|
||||||
"""
|
"""
|
||||||
# https://github.com/frida/frida/issues/1225#issuecomment-604181822
|
# https://github.com/frida/frida/issues/1225#issuecomment-604181822
|
||||||
processes = {}
|
processes = {}
|
||||||
|
|
||||||
# Try to get the list of processes using `ps -A`
|
# Attempt to get the list of processes using the 'ps -A' command
|
||||||
prompt = [*self.prompt, 'ps']
|
prompt = [*self.prompt, 'ps']
|
||||||
sp = shell([*prompt, '-A'])
|
sp = shell([*prompt, '-A'])
|
||||||
lines = sp.stdout.decode('utf-8').splitlines()
|
lines = sp.stdout.decode("utf-8").splitlines()
|
||||||
|
|
||||||
# If the output has less than 10 lines, try the alternative `ps` command
|
# If the output has less than 10 lines, retry with a simpler 'ps' command
|
||||||
if len(lines) < 10:
|
if len(lines) < 10:
|
||||||
sp = shell(prompt)
|
sp = shell(prompt)
|
||||||
if sp.returncode != 0:
|
if sp.returncode != 0:
|
||||||
self.logger.error('Failed to execute ps command (Error: %s)', sp.stdout.decode('utf-8').strip())
|
self.logger.error("Failed to execute ps command (Error: %s)", sp.stdout.decode("utf-8").strip())
|
||||||
return processes
|
return processes
|
||||||
lines = sp.stdout.decode('utf-8').splitlines()
|
lines = sp.stdout.decode("utf-8").splitlines()
|
||||||
|
|
||||||
# Iterate through lines starting from the second line (skipping header)
|
# Iterate through lines starting from the second line (skipping header)
|
||||||
for line in lines[1:]:
|
for line in lines[1:]:
|
||||||
try:
|
try:
|
||||||
parts = line.split() # USER,PID,PPID,VSZ,RSS,WCHAN,ADDR,S,NAME
|
parts = line.split() # USER,PID,PPID,VSZ,RSS,WCHAN,ADDR,S,NAME
|
||||||
pid = int(parts[1]) # Extract PID
|
pid = int(parts[1]) # Extract PID
|
||||||
name = ' '.join(parts[8:]).strip() # Extract process name
|
name = " ".join(parts[8:]).strip() # Extract process name
|
||||||
|
|
||||||
# Handle cases where the name might be in brackets
|
# Handle cases where process name might be in brackets (e.g., kernel threads)
|
||||||
name = name if name.startswith('[') else Path(name).name
|
name = name if name.startswith("[") else Path(name).name
|
||||||
processes[name] = pid
|
processes[name] = pid
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
pass
|
pass
|
||||||
|
@ -223,61 +225,63 @@ class ADB:
|
||||||
|
|
||||||
def install_application(self, path: Path = None, url: str = None) -> bool:
|
def install_application(self, path: Path = None, url: str = None) -> bool:
|
||||||
"""
|
"""
|
||||||
Installs an application from a local file path or a URL.
|
Installs an application on the device either from a local file or by downloading from a URL.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
path (Path, optional): The local file path of the APK to install. Defaults to None.
|
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.
|
url (str, optional): The URL to download the APK from. Defaults to None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the installation was successful, False otherwise.
|
bool: True if the installation was successful, False otherwise.
|
||||||
"""
|
"""
|
||||||
|
# Prepare the shell command for installation
|
||||||
prompt = [*self.prompt[:-1], 'install']
|
prompt = [*self.prompt[:-1], 'install']
|
||||||
|
|
||||||
# Install from a local file path if provided
|
# Install from a local file path if a valid path is provided
|
||||||
if path and path.is_file():
|
if path and path.is_file():
|
||||||
sp = shell([*prompt, path])
|
sp = shell([*prompt, path]) # Run the installation command with the local file path
|
||||||
if sp.returncode == 0:
|
if sp.returncode == 0:
|
||||||
return True
|
return True
|
||||||
self.logger.error('Installation failed for local path: %s (Error: %s)', path, sp.stdout.decode('utf-8').strip())
|
self.logger.error("Installation failed for local path: %s (Error: %s)", path, sp.stdout.decode("utf-8").strip())
|
||||||
|
|
||||||
# Install from a URL if provided
|
# If URL is provided, attempt to download the APK and install it
|
||||||
status = False
|
status = False
|
||||||
if url:
|
if url:
|
||||||
file = Path('tmp.apk')
|
file = Path("tmp.apk") # Temporary file to store the downloaded APK
|
||||||
try:
|
try:
|
||||||
r = requests.get(url, headers={'Accept': '*/*', 'User-Agent': 'KeyDive/ADB'})
|
# Download the APK from the provided URL
|
||||||
|
r = requests.get(url, headers={"Accept": "*/*", "User-Agent": "KeyDive/ADB"})
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
# Write downloaded content to temporary APK file
|
# Save the downloaded APK to a temporary file
|
||||||
file.write_bytes(r.content)
|
file.write_bytes(r.content)
|
||||||
|
|
||||||
# Attempt installation from the downloaded file
|
# Attempt installation from the downloaded APK
|
||||||
status = self.install_application(path=file)
|
status = self.install_application(path=file)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error('Failed to download application from URL: %s (Error: %s)', url, e)
|
self.logger.error("Failed to download application from URL: %s (Error: %s)", url, e)
|
||||||
file.unlink(missing_ok=True)
|
file.unlink(missing_ok=True) # Clean up the temporary file, even if there was an error
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|
||||||
def open_url(self, url: str) -> bool:
|
def open_url(self, url: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Opens a specified URL on the connected device.
|
Opens a specified URL on the device.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
url (str): The URL to open on the device.
|
url (str): The URL to be opened on the device.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the URL was opened successfully, False otherwise.
|
bool: True if the URL was successfully opened, False otherwise.
|
||||||
"""
|
"""
|
||||||
# Execute the shell command to open the URL
|
# 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])
|
sp = shell([*self.prompt, 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url])
|
||||||
|
|
||||||
# Check the result and log accordingly
|
# Check the result of the command execution and log if there is an error
|
||||||
if sp.returncode != 0:
|
if sp.returncode != 0:
|
||||||
self.logger.error('URL open failed for: %s (Return: %s)', url, sp.stdout.decode('utf-8').strip())
|
self.logger.error("URL open failed for: %s (Return: %s)", url, sp.stdout.decode("utf-8").strip())
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('ADB',)
|
__all__ = ("ADB",)
|
||||||
|
|
159
keydive/cdm.py
159
keydive/cdm.py
|
@ -2,17 +2,18 @@ import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Union
|
from typing import Union
|
||||||
from zlib import crc32
|
from zlib import crc32
|
||||||
from unidecode import unidecode
|
from unidecode import unidecode
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pathvalidate import sanitize_filepath, sanitize_filename
|
from pathvalidate import sanitize_filepath, sanitize_filename
|
||||||
from Cryptodome.PublicKey import RSA
|
from Cryptodome.PublicKey import RSA
|
||||||
from Cryptodome.PublicKey.RSA import RsaKey
|
from Cryptodome.PublicKey.RSA import RsaKey
|
||||||
from pywidevine.device import Device, DeviceTypes
|
from pywidevine.device import Device, DeviceTypes
|
||||||
from pywidevine.license_protocol_pb2 import (SignedMessage, LicenseRequest, ClientIdentification, SignedDrmCertificate,
|
from pywidevine.license_protocol_pb2 import (
|
||||||
DrmCertificate, EncryptedClientIdentification)
|
SignedMessage, LicenseRequest, ClientIdentification, SignedDrmCertificate, DrmCertificate,
|
||||||
|
EncryptedClientIdentification)
|
||||||
|
|
||||||
from keydive.constants import OEM_CRYPTO_API
|
from keydive.constants import OEM_CRYPTO_API
|
||||||
from keydive.keybox import Keybox
|
from keydive.keybox import Keybox
|
||||||
|
@ -28,15 +29,15 @@ class Cdm:
|
||||||
"""
|
"""
|
||||||
Initializes the Cdm object, setting up a logger and containers for client IDs and private keys.
|
Initializes the Cdm object, setting up a logger and containers for client IDs and private keys.
|
||||||
|
|
||||||
Attributes:
|
Parameters:
|
||||||
client_id (dict[int, ClientIdentification]): Stores client identification info mapped by key modulus.
|
|
||||||
private_key (dict[int, RsaKey]): Stores private keys mapped by key modulus.
|
|
||||||
keybox (bool, optional): Initializes a Keybox instance for secure key management.
|
keybox (bool, optional): Initializes a Keybox instance for secure key management.
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger(self.__class__.__name__)
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
# https://github.com/devine-dl/pywidevine
|
# https://github.com/devine-dl/pywidevine
|
||||||
self.client_id: dict[int, ClientIdentification] = {}
|
self.client_id: dict[int, ClientIdentification] = {}
|
||||||
self.private_key: dict[int, RsaKey] = {}
|
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
|
self.keybox = Keybox() if keybox else None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -44,7 +45,7 @@ class Cdm:
|
||||||
"""
|
"""
|
||||||
Converts client identification information to a dictionary.
|
Converts client identification information to a dictionary.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
client_id (ClientIdentification): The client identification.
|
client_id (ClientIdentification): The client identification.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
@ -57,120 +58,143 @@ class Cdm:
|
||||||
"""
|
"""
|
||||||
Converts encrypted client identification information to a dictionary.
|
Converts encrypted client identification information to a dictionary.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
encrypted_client_id (EncryptedClientIdentification): The encrypted client identification.
|
encrypted_client_id (EncryptedClientIdentification): The encrypted client identification.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary of encrypted client information.
|
dict: A dictionary of encrypted client information.
|
||||||
"""
|
"""
|
||||||
content = {
|
content = {
|
||||||
'providerId': encrypted_client_id.provider_id,
|
"providerId": encrypted_client_id.provider_id,
|
||||||
'serviceCertificateSerialNumber': encrypted_client_id.service_certificate_serial_number,
|
"serviceCertificateSerialNumber": encrypted_client_id.service_certificate_serial_number,
|
||||||
'encryptedClientId': encrypted_client_id.encrypted_client_id,
|
"encryptedClientId": encrypted_client_id.encrypted_client_id,
|
||||||
'encryptedClientIdIv': encrypted_client_id.encrypted_client_id_iv,
|
"encryptedClientIdIv": encrypted_client_id.encrypted_client_id_iv,
|
||||||
'encryptedPrivacyKey': encrypted_client_id.encrypted_privacy_key
|
"encryptedPrivacyKey": encrypted_client_id.encrypted_privacy_key
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
k: base64.b64encode(v).decode('utf-8') if isinstance(v, bytes) else v
|
k: base64.b64encode(v).decode("utf-8") if isinstance(v, bytes) else v
|
||||||
for k, v in content.items()
|
for k, v in content.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
def set_challenge(self, data: Union[Path, bytes]) -> None:
|
def set_challenge(self, data: Union[Path, bytes]) -> None:
|
||||||
"""
|
"""
|
||||||
Sets the challenge data by extracting device information.
|
Sets the challenge data by extracting device information and client ID.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (Union[Path, bytes]): The challenge data as a file path or bytes.
|
data (Union[Path, bytes]): Challenge data as a file path or raw bytes.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If the provided file path does not exist.
|
FileNotFoundError: If the file path doesn't exist.
|
||||||
|
Exception: Logs any other exceptions that occur.
|
||||||
"""
|
"""
|
||||||
if isinstance(data, Path):
|
|
||||||
if not data.is_file():
|
|
||||||
raise FileNotFoundError(data)
|
|
||||||
data = data.read_bytes()
|
|
||||||
|
|
||||||
try:
|
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 = SignedMessage()
|
||||||
signed_message.ParseFromString(data)
|
signed_message.ParseFromString(data)
|
||||||
|
|
||||||
|
# Parse the license request from the signed message
|
||||||
license_request = LicenseRequest()
|
license_request = LicenseRequest()
|
||||||
license_request.ParseFromString(signed_message.msg)
|
license_request.ParseFromString(signed_message.msg)
|
||||||
|
|
||||||
|
# Extract the encrypted client ID, if available
|
||||||
# https://integration.widevine.com/diagnostics
|
# https://integration.widevine.com/diagnostics
|
||||||
encrypted_client_id: EncryptedClientIdentification = license_request.encrypted_client_id
|
encrypted_client_id: EncryptedClientIdentification = license_request.encrypted_client_id
|
||||||
if encrypted_client_id.SerializeToString():
|
if encrypted_client_id.SerializeToString():
|
||||||
self.logger.info('Receive encrypted client id: \n\n%s\n', json.dumps(self.__encrypted_client_info(encrypted_client_id), indent=2))
|
# If encrypted, log the encrypted client ID and indicate encryption
|
||||||
self.logger.warning('The client ID of the challenge is encrypted')
|
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:
|
else:
|
||||||
|
# If unencrypted, extract and set the client ID
|
||||||
client_id: ClientIdentification = license_request.client_id
|
client_id: ClientIdentification = license_request.client_id
|
||||||
self.set_client_id(data=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:
|
except Exception as e:
|
||||||
self.logger.debug('Failed to set challenge data: %s', e)
|
self.logger.debug("Failed to set challenge data: %s", e)
|
||||||
|
|
||||||
def set_private_key(self, data: Union[Path, bytes], name: str = None) -> None:
|
def set_private_key(self, data: Union[Path, bytes], name: str = None) -> None:
|
||||||
"""
|
"""
|
||||||
Sets the private key from the provided data.
|
Sets the private key from the provided data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (Union[Path, bytes]): The private key data, either as a file path or bytes.
|
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.
|
name (str, optional): Function name for verification against known functions.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
FileNotFoundError: If the provided file path does not exist or is not a file.
|
FileNotFoundError: If the file path doesn't exist.
|
||||||
|
Exception: Logs any other exceptions that occur.
|
||||||
"""
|
"""
|
||||||
if isinstance(data, Path):
|
|
||||||
if not data.is_file():
|
|
||||||
raise FileNotFoundError(data)
|
|
||||||
data = data.read_bytes()
|
|
||||||
|
|
||||||
try:
|
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)
|
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:
|
if key.n not in self.private_key:
|
||||||
self.logger.info('Receive private key: \n\n%s\n', key.exportKey('PEM').decode('utf-8'))
|
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:
|
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)
|
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
|
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:
|
except Exception as e:
|
||||||
self.logger.debug('Failed to set private key: %s', e)
|
self.logger.debug("Failed to set private key: %s", e)
|
||||||
|
|
||||||
def set_client_id(self, data: Union[ClientIdentification, bytes]) -> None:
|
def set_client_id(self, data: Union[ClientIdentification, bytes]) -> None:
|
||||||
"""
|
"""
|
||||||
Sets the client ID from the provided data.
|
Sets the client ID from the provided data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (Union[ClientIdentification, bytes]): The client ID data.
|
data (Union[ClientIdentification, bytes]): The client ID data.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Check if the provided data is already a `ClientIdentification` object
|
||||||
if isinstance(data, ClientIdentification):
|
if isinstance(data, ClientIdentification):
|
||||||
client_id = data
|
client_id = data
|
||||||
else:
|
else:
|
||||||
|
# Deserialize the byte data into a `ClientIdentification` object
|
||||||
client_id = ClientIdentification()
|
client_id = ClientIdentification()
|
||||||
client_id.ParseFromString(data)
|
client_id.ParseFromString(data)
|
||||||
|
|
||||||
|
# Initialize objects for parsing the DRM certificate and signed certificate
|
||||||
signed_drm_certificate = SignedDrmCertificate()
|
signed_drm_certificate = SignedDrmCertificate()
|
||||||
drm_certificate = DrmCertificate()
|
drm_certificate = DrmCertificate()
|
||||||
|
|
||||||
|
# Parse the signed DRM certificate from the client ID token
|
||||||
signed_drm_certificate.ParseFromString(client_id.token)
|
signed_drm_certificate.ParseFromString(client_id.token)
|
||||||
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
drm_certificate.ParseFromString(signed_drm_certificate.drm_certificate)
|
||||||
|
|
||||||
|
# Extract the public key from the DRM certificate
|
||||||
public_key = drm_certificate.public_key
|
public_key = drm_certificate.public_key
|
||||||
key = RSA.importKey(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:
|
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))
|
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
|
self.client_id[key.n] = client_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug('Failed to set client ID: %s', e)
|
self.logger.debug("Failed to set client ID: %s", e)
|
||||||
|
|
||||||
def set_device_id(self, data: bytes) -> None:
|
def set_device_id(self, data: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
Sets the device ID in the keybox if it is enabled.
|
Sets the device ID in the keybox.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (bytes): The device ID data to be stored in the keybox.
|
data (bytes): The device ID to be stored in the keybox.
|
||||||
"""
|
"""
|
||||||
if self.keybox:
|
if self.keybox:
|
||||||
self.keybox.set_device_id(data=data)
|
self.keybox.set_device_id(data=data)
|
||||||
|
@ -179,7 +203,7 @@ class Cdm:
|
||||||
"""
|
"""
|
||||||
Sets the keybox data.
|
Sets the keybox data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (bytes): The keybox data to be set.
|
data (bytes): The keybox data to be set.
|
||||||
"""
|
"""
|
||||||
if self.keybox:
|
if self.keybox:
|
||||||
|
@ -187,56 +211,69 @@ class Cdm:
|
||||||
|
|
||||||
def export(self, parent: Path, wvd: bool = False) -> bool:
|
def export(self, parent: Path, wvd: bool = False) -> bool:
|
||||||
"""
|
"""
|
||||||
Exports the client ID and private key to disk.
|
Exports client ID, private key, and optionally WVD files to disk.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
parent (Path): The parent directory to export the files to.
|
parent (Path): Directory to export the files to.
|
||||||
wvd (bool): Whether to export WVD files.
|
wvd (bool, optional): Whether to export WVD files. Defaults to False.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if any keys were exported, otherwise False.
|
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()
|
keys = self.client_id.keys() & self.private_key.keys()
|
||||||
|
|
||||||
for k in keys:
|
for k in keys:
|
||||||
|
# Retrieve client information based on the client ID
|
||||||
client_info = self.__client_info(self.client_id[k])
|
client_info = self.__client_info(self.client_id[k])
|
||||||
|
|
||||||
# https://github.com/devine-dl/pywidevine/blob/master/pywidevine/main.py#L211
|
# https://github.com/devine-dl/pywidevine/blob/master/pywidevine/main.py#L211
|
||||||
device = Device(
|
device = Device(
|
||||||
client_id=self.client_id[k].SerializeToString(),
|
client_id=self.client_id[k].SerializeToString(),
|
||||||
private_key=self.private_key[k].exportKey('PEM'),
|
private_key=self.private_key[k].exportKey("PEM"),
|
||||||
type_=DeviceTypes.ANDROID,
|
type_=DeviceTypes.ANDROID,
|
||||||
security_level=3,
|
security_level=3,
|
||||||
flags=None
|
flags=None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Generate a sanitized file path for exporting the data
|
||||||
# https://github.com/hyugogirubato/KeyDive/issues/14#issuecomment-2146958022
|
# 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 = 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)
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
path_id_bin = parent / 'client_id.bin'
|
# 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())
|
path_id_bin.write_bytes(data=device.client_id.SerializeToString())
|
||||||
self.logger.info('Exported client ID: %s', path_id_bin)
|
self.logger.info("Exported client ID: %s", path_id_bin)
|
||||||
|
|
||||||
path_key_bin = parent / 'private_key.pem'
|
# Export the private key to a PEM file
|
||||||
path_key_bin.write_bytes(data=device.private_key.exportKey('PEM'))
|
path_key_bin = parent / "private_key.pem"
|
||||||
self.logger.info('Exported private key: %s', path_key_bin)
|
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:
|
if wvd:
|
||||||
|
# Serialize the device to WVD format
|
||||||
wvd_bin = device.dumps()
|
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']}"
|
name = f"{client_info['company_name']} {client_info['model_name']}"
|
||||||
if client_info.get('widevine_cdm_version'):
|
if client_info.get("widevine_cdm_version"):
|
||||||
name += f" {client_info['widevine_cdm_version']}"
|
name += f" {client_info['widevine_cdm_version']}"
|
||||||
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
|
name += f" {crc32(wvd_bin).to_bytes(4, 'big').hex()}"
|
||||||
name = unidecode(name.strip().lower().replace(' ', '_'))
|
name = unidecode(name.strip().lower().replace(" ", "_"))
|
||||||
path_wvd = parent / sanitize_filename(f'{name}_{device.system_id}_l{device.security_level}.wvd')
|
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)
|
path_wvd.write_bytes(data=wvd_bin)
|
||||||
self.logger.info('Exported WVD: %s', path_wvd)
|
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):
|
if self.keybox and not self.keybox.export(parent=parent.parent):
|
||||||
self.logger.warning('The keybox has not been intercepted or decrypted')
|
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
|
return len(keys) > 0
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('Cdm',)
|
__all__ = ("Cdm",)
|
||||||
|
|
|
@ -1,143 +1,145 @@
|
||||||
import re
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from keydive.vendor import Vendor
|
from keydive.vendor import Vendor
|
||||||
|
|
||||||
# https://developer.android.com/ndk/guides/cpp-support
|
# https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:bionic/libc/libc.map.txt
|
||||||
NATIVE_C_API = {
|
NATIVE_C_API = {
|
||||||
# BUILT-IN
|
# BUILT-IN
|
||||||
'main',
|
"main",
|
||||||
# STDIO
|
# STDIO
|
||||||
'fclose', 'fflush', 'fgetc', 'fgetpos', 'fgets', 'fopen', 'fprintf', 'fputc', 'fputs', 'fread', 'freopen',
|
"fclose", "fflush", "fgetc", "fgetpos", "fgets", "fopen", "fprintf", "fputc", "fputs", "fread", "freopen",
|
||||||
'fscanf', 'fseek', 'fsetpos', 'ftell', 'fwrite', 'getc', 'getchar', 'gets', 'perror', 'printf', 'putc',
|
"fscanf", "fseek", "fsetpos", "ftell", "fwrite", "getc", "getchar", "gets", "perror", "printf", "putc",
|
||||||
'putchar', 'puts', 'remove', 'rename', 'rewind', 'scanf', 'setbuf', 'setvbuf', 'sprintf', 'sscanf', 'tmpfile',
|
"putchar", "puts", "remove", "rename", "rewind", "scanf", "setbuf", "setvbuf", "sprintf", "sscanf", "tmpfile",
|
||||||
'tmpnam', 'ungetc', 'vfprintf', 'vprintf', 'vsprintf', 'fileno', 'feof', 'ferror', 'snprintf',
|
"tmpnam", "ungetc", "vfprintf", "vprintf", "vsprintf", "fileno", "feof", "ferror", "snprintf",
|
||||||
# STDLIB
|
# STDLIB
|
||||||
'abort', 'abs', 'atexit', 'atof', 'atoi', 'atol', 'bsearch', 'calloc', 'div', 'exit', 'free', 'getenv', 'labs',
|
"abort", "abs", "atexit", "atof", "atoi", "atol", "bsearch", "calloc", "div", "exit", "free", "getenv", "labs",
|
||||||
'ldiv', 'malloc', 'mblen', 'mbstowcs', 'mbtowc', 'qsort', 'rand', 'realloc', 'srand', 'strtod', 'strtol',
|
"ldiv", "malloc", "mblen", "mbstowcs", "mbtowc", "qsort", "rand", "realloc", "srand", "strtod", "strtol",
|
||||||
'strtoul', 'system', 'wcstombs', 'wctomb',
|
"strtoul", "system", "wcstombs", "wctomb",
|
||||||
# STRING
|
# STRING
|
||||||
'memchr', 'memcmp', 'memcpy', 'memmove', 'memset', 'strcat', 'strchr', 'strcmp', 'strcoll', 'strcpy', 'strcspn',
|
"memchr", "memcmp", "memcpy", "memmove", "memset", "strcat", "strchr", "strcmp", "strcoll", "strcpy", "strcspn",
|
||||||
'strerror', 'strlen', 'strncat', 'strncmp', 'strncpy', 'strpbrk', 'strrchr', 'strspn', 'strstr', 'strtok',
|
"strerror", "strlen", "strncat", "strncmp", "strncpy", "strpbrk", "strrchr", "strspn", "strstr", "strtok",
|
||||||
'strxfrm', 'strncasecmp',
|
"strxfrm", "strncasecmp",
|
||||||
# MATH
|
# MATH
|
||||||
'acos', 'asin', 'atan', 'atan2', 'cos', 'cosh', 'exp', 'fabs', 'floor', 'fmod', 'frexp', 'ldexp', 'log',
|
"acos", "asin", "atan", "atan2", "cos", "cosh", "exp", "fabs", "floor", "fmod", "frexp", "ldexp", "log",
|
||||||
'log10', 'modf', 'pow', 'sin', 'sinh', 'sqrt', 'tan', 'tanh',
|
"log10", "modf", "pow", "sin", "sinh", "sqrt", "tan", "tanh",
|
||||||
# CTYPE
|
# CTYPE
|
||||||
'isalnum', 'isalpha', 'iscntrl', 'isdigit', 'isgraph', 'islower', 'isprint', 'ispunct', 'isspace', 'isupper',
|
"isalnum", "isalpha", "iscntrl", "isdigit", "isgraph", "islower", "isprint", "ispunct", "isspace", "isupper",
|
||||||
'isxdigit', 'tolower', 'toupper',
|
"isxdigit", "tolower", "toupper",
|
||||||
# TIME
|
# TIME
|
||||||
'asctime', 'clock', 'ctime', 'difftime', 'gmtime', 'localtime', 'mktime', 'strftime', 'time',
|
"asctime", "clock", "ctime", "difftime", "gmtime", "localtime", "mktime", "strftime", "time",
|
||||||
# UNISTD
|
# UNISTD
|
||||||
'access', 'alarm', 'chdir', 'chown', 'close', 'dup', 'dup2', 'execle', 'execv', 'execve', 'execvp', 'fork',
|
"access", "alarm", "chdir", "chown", "close", "dup", "dup2", "execle", "execv", "execve", "execvp", "fork",
|
||||||
'fpathconf', 'getcwd', 'getegid', 'geteuid', 'getgid', 'getgroups', 'getlogin', 'getopt', 'getpgid', 'getpgrp',
|
"fpathconf", "getcwd", "getegid", "geteuid", "getgid", "getgroups", "getlogin", "getopt", "getpgid", "getpgrp",
|
||||||
'getpid', 'getppid', 'getuid', 'isatty', 'lseek', 'pathconf', 'pause', 'pipe', 'read', 'rmdir', 'setgid',
|
"getpid", "getppid", "getuid", "isatty", "lseek", "pathconf", "pause", "pipe", "read", "rmdir", "setgid",
|
||||||
'setpgid', 'setsid', 'setuid', 'sleep', 'sysconf', 'tcgetpgrp', 'tcsetpgrp', 'ttyname', 'ttyname_r', 'write',
|
"setpgid", "setsid", "setuid", "sleep", "sysconf", "tcgetpgrp", "tcsetpgrp", "ttyname", "ttyname_r", "write",
|
||||||
'fsync', 'unlink', 'syscall', 'getpagesize',
|
"fsync", "unlink", "syscall", "getpagesize",
|
||||||
# FCNTL
|
# FCNTL
|
||||||
'creat', 'fcntl', 'open',
|
"creat", "fcntl", "open",
|
||||||
# SYS_TYPE
|
# SYS_TYPE
|
||||||
'fd_set', 'FD_CLR', 'FD_ISSET', 'FD_SET', 'FD_ZERO',
|
"fd_set", "FD_CLR", "FD_ISSET", "FD_SET", "FD_ZERO",
|
||||||
# SYS_STAT
|
# SYS_STAT
|
||||||
'chmod', 'fchmod', 'fstat', 'mkdir', 'mkfifo', 'stat', 'umask',
|
"chmod", "fchmod", "fstat", "mkdir", "mkfifo", "stat", "umask",
|
||||||
# SYS_TIME
|
# SYS_TIME
|
||||||
'gettimeofday', 'select', 'settimeofday',
|
"gettimeofday", "select", "settimeofday",
|
||||||
# SIGNAL
|
# SIGNAL
|
||||||
'signal', 'raise', 'kill', 'sigaction', 'sigaddset', 'sigdelset', 'sigemptyset', 'sigfillset', 'sigismember',
|
"signal", "raise", "kill", "sigaction", "sigaddset", "sigdelset", "sigemptyset", "sigfillset", "sigismember",
|
||||||
'sigpending', 'sigprocmask', 'sigsuspend', 'alarm', 'pause',
|
"sigpending", "sigprocmask", "sigsuspend", "alarm", "pause",
|
||||||
# SETJMP
|
# SETJMP
|
||||||
'longjmp', 'setjmp',
|
"longjmp", "setjmp",
|
||||||
# ERRNO
|
# ERRNO
|
||||||
'errno', 'strerror', 'perror',
|
"errno", "strerror", "perror",
|
||||||
# ASSERT
|
# ASSERT
|
||||||
'assert',
|
"assert",
|
||||||
# LOCAL
|
# LOCAL
|
||||||
'localeconv', 'setlocale',
|
"localeconv", "setlocale",
|
||||||
# WCHAR
|
# WCHAR
|
||||||
'btowc', 'fgetwc', 'fgetws', 'fputwc', 'fputws', 'fwide', 'fwprintf', 'fwscanf', 'getwc', 'getwchar', 'mbrlen',
|
"btowc", "fgetwc", "fgetws", "fputwc", "fputws", "fwide", "fwprintf", "fwscanf", "getwc", "getwchar", "mbrlen",
|
||||||
'mbrtowc', 'mbsinit', 'mbsrtowcs', 'putwc', 'putwchar', 'swprintf', 'swscanf', 'ungetwc', 'vfwprintf',
|
"mbrtowc", "mbsinit", "mbsrtowcs", "putwc", "putwchar", "swprintf", "swscanf", "ungetwc", "vfwprintf",
|
||||||
'vfwscanf', 'vwprintf', 'vwscanf', 'wcrtomb', 'wcscat', 'wcschr', 'wcscmp', 'wcscoll', 'wcscpy', 'wcscspn',
|
"vfwscanf", "vwprintf", "vwscanf", "wcrtomb", "wcscat", "wcschr", "wcscmp", "wcscoll", "wcscpy", "wcscspn",
|
||||||
'wcsftime', 'wcslen', 'wcsncat', 'wcsncmp', 'wcsncpy', 'wcspbrk', 'wcsrchr', 'wcsrtombs', 'wcsspn', 'wcsstr',
|
"wcsftime", "wcslen", "wcsncat", "wcsncmp", "wcsncpy", "wcspbrk", "wcsrchr", "wcsrtombs", "wcsspn", "wcsstr",
|
||||||
'wcstod', 'wcstok', 'wcstol', 'wcstombs', 'wcstoul', 'wcsxfrm', 'wctob', 'wmemchr', 'wmemcmp', 'wmemcpy',
|
"wcstod", "wcstok", "wcstol", "wcstombs", "wcstoul", "wcsxfrm", "wctob", "wmemchr", "wmemcmp", "wmemcpy",
|
||||||
'wmemmove', 'wmemset', 'wprintf', 'wscanf',
|
"wmemmove", "wmemset", "wprintf", "wscanf",
|
||||||
# WCTYPE
|
# WCTYPE
|
||||||
'iswalnum', 'iswalpha', 'iswcntrl', 'iswdigit', 'iswgraph', 'iswlower', 'iswprint', 'iswpunct', 'iswspace',
|
"iswalnum", "iswalpha", "iswcntrl", "iswdigit", "iswgraph", "iswlower", "iswprint", "iswpunct", "iswspace",
|
||||||
'iswupper', 'iswxdigit', 'towlower', 'towupper', 'iswctype', 'wctype',
|
"iswupper", "iswxdigit", "towlower", "towupper", "iswctype", "wctype",
|
||||||
# STDDEF
|
# STDDEF
|
||||||
'NULL', 'offsetof', 'ptrdiff_t', 'size_t', 'wchar_t',
|
"NULL", "offsetof", "ptrdiff_t", "size_t", "wchar_t",
|
||||||
# STDARG
|
# STDARG
|
||||||
'va_arg', 'va_end', 'va_start',
|
"va_arg", "va_end", "va_start",
|
||||||
# DLFCN
|
# DLFCN
|
||||||
'dlclose', 'dlerror', 'dlopen', 'dlsym',
|
"dlclose", "dlerror", "dlopen", "dlsym",
|
||||||
# DIRENT
|
# DIRENT
|
||||||
'closedir', 'opendir', 'readdir',
|
"closedir", "opendir", "readdir",
|
||||||
# SYS_SENDFILE
|
# SYS_SENDFILE
|
||||||
'sendfile',
|
"sendfile",
|
||||||
# SYS_MMAN
|
# SYS_MMAN
|
||||||
'mmap', 'mprotect', 'munmap',
|
"mmap", "mprotect", "munmap",
|
||||||
# SYS_UTSNAME
|
# SYS_UTSNAME
|
||||||
'uname',
|
"uname",
|
||||||
# LINK
|
# LINK
|
||||||
'dladdr'
|
"dladdr"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# https://cs.android.com/search?q=oemcrypto&sq=&ss=android%2Fplatform%2Fsuperproject
|
||||||
OEM_CRYPTO_API = {
|
OEM_CRYPTO_API = {
|
||||||
# Mapping of function names across different API levels (obfuscated names may vary).
|
# Mapping of function names across different API levels (obfuscated names may vary).
|
||||||
'rnmsglvj', 'polorucp', 'kqzqahjq', 'pldrclfq', 'kgaitijd', 'cwkfcplc', 'crhqcdet', 'ulns', 'dnvffnze', 'ygjiljer',
|
"rnmsglvj", "polorucp", "kqzqahjq", "pldrclfq", "kgaitijd", "cwkfcplc", "crhqcdet", "ulns", "dnvffnze", "ygjiljer",
|
||||||
'qbjxtubz', 'qkfrcjtw', 'rbhjspoh', 'zgtjmxko', 'igrqajte', 'ofskesua', 'qllcoacg', 'pukctkiv', 'ehdqmfmd',
|
"qbjxtubz", "qkfrcjtw", "rbhjspoh", "zgtjmxko", "igrqajte", "ofskesua", "qllcoacg", "pukctkiv", "ehdqmfmd",
|
||||||
'xftzvkwx', 'gndskkuk', 'wcggmnnx', 'kaatohcz', 'ktmgdchz', 'jkcwonus', 'ehmduqyt', 'vewtuecx', 'mxrbzntq',
|
"xftzvkwx", "gndskkuk", "wcggmnnx", "kaatohcz", "ktmgdchz", "jkcwonus", "ehmduqyt", "vewtuecx", "mxrbzntq",
|
||||||
'isyowgmp', 'flzfkhbc'
|
"isyowgmp", "flzfkhbc"
|
||||||
# Add more as needed for different versions.
|
# Add more as needed for different versions.
|
||||||
}
|
}
|
||||||
|
|
||||||
# https://developer.android.com/tools/releases/platforms
|
# https://developer.android.com/tools/releases/platforms
|
||||||
CDM_VENDOR_API = {
|
CDM_VENDOR_API = {
|
||||||
'mediaserver': [
|
"mediaserver": [
|
||||||
Vendor(22, 11, '1.0', r'libwvdrmengine(?:@\S+)?\.so')
|
Vendor(22, 11, "1.0", r"libwvdrmengine(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'mediadrmserver': [
|
"mediadrmserver": [
|
||||||
Vendor(24, 11, '1.0', r'libwvdrmengine(?:@\S+)?\.so')
|
Vendor(24, 11, "1.0", r"libwvdrmengine(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'android.hardware.drm@1.0-service.widevine': [
|
"android.hardware.drm@1.0-service.widevine": [
|
||||||
Vendor(26, 13, '5.1.0', r'libwvhidl(?:@\S+)?\.so')
|
Vendor(26, 13, "5.1.0", r"libwvhidl(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'android.hardware.drm@1.1-service.widevine': [
|
"android.hardware.drm@1.1-service.widevine": [
|
||||||
Vendor(28, 14, '14.0.0', r'libwvhidl(?:@\S+)?\.so')
|
Vendor(28, 14, "14.0.0", r"libwvhidl(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'android.hardware.drm@1.2-service.widevine': [
|
"android.hardware.drm@1.2-service.widevine": [
|
||||||
Vendor(29, 15, '15.0.0', r'libwvhidl(?:@\S+)?\.so')
|
Vendor(29, 15, "15.0.0", r"libwvhidl(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'android.hardware.drm@1.3-service.widevine': [
|
"android.hardware.drm@1.3-service.widevine": [
|
||||||
Vendor(30, 16, '16.0.0', r'libwvhidl(?:@\S+)?\.so')
|
Vendor(30, 16, "16.0.0", r"libwvhidl(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'android.hardware.drm@1.4-service.widevine': [
|
"android.hardware.drm@1.4-service.widevine": [
|
||||||
Vendor(31, 16, '16.1.0', r'libwvhidl(?:@\S+)?\.so')
|
Vendor(31, 16, "16.1.0", r"libwvhidl(?:@\S+)?\.so")
|
||||||
],
|
],
|
||||||
'android.hardware.drm-service.widevine': [
|
"android.hardware.drm-service.widevine": [
|
||||||
Vendor(33, 17, '17.0.0', r'libwvaidl(?:@\S+)?\.so'),
|
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(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+)?')
|
Vendor(35, 18, "19.0.1", r"android\.hardware\.drm-service(?:-lazy)?\.widevine(?:@\S+)?")
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# https://developers.google.com/widevine
|
# https://developers.google.com/widevine
|
||||||
CDM_FUNCTION_API = {
|
CDM_FUNCTION_API = {
|
||||||
'UsePrivacyMode',
|
"UsePrivacyMode",
|
||||||
'GetCdmClientPropertySet',
|
"GetCdmClientPropertySet",
|
||||||
'PrepareKeyRequest',
|
"PrepareKeyRequest",
|
||||||
'getOemcryptoDeviceId',
|
"getOemcryptoDeviceId",
|
||||||
'lcc07',
|
"lcc07",
|
||||||
'oecc07',
|
"oecc07",
|
||||||
'Read',
|
"Read",
|
||||||
'x1c36',
|
"x1c36",
|
||||||
'runningcrc'
|
"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
|
KEYBOX_MAX_CLEAR_API = 28
|
||||||
|
|
||||||
# https://github.com/kaltura/kaltura-device-info-android
|
# https://github.com/kaltura/kaltura-device-info-android
|
||||||
DRM_PLAYER = {
|
DRM_PLAYER = {
|
||||||
'package': 'com.kaltura.kalturadeviceinfo',
|
"package": "com.kaltura.kalturadeviceinfo",
|
||||||
'path': Path(__file__).parent.parent / 'docs' / 'server' / 'kaltura.apk',
|
"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'
|
"url": "https://github.com/kaltura/kaltura-device-info-android/releases/download/t3/kaltura-device-info-release.apk"
|
||||||
}
|
}
|
||||||
|
|
156
keydive/core.py
156
keydive/core.py
|
@ -24,55 +24,59 @@ class Core:
|
||||||
"""
|
"""
|
||||||
Initializes a Core instance.
|
Initializes a Core instance.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
adb (ADB): ADB instance for device communication.
|
adb (ADB): ADB instance for device communication.
|
||||||
cdm (Cdm): Instance of Cdm for managing DRM related operations.
|
cdm (Cdm): Instance for handling DRM-related operations.
|
||||||
functions (Path, optional): Path to Ghidra XML functions file for symbol extraction. Defaults to None.
|
functions (Path, optional): Path to Ghidra XML file for symbol extraction. Defaults to None.
|
||||||
skip (bool, optional): Flag to determine whether to skip predefined functions (e.g., OEM_CRYPTO_API).
|
skip (bool, optional): Whether to skip predefined functions (e.g., OEM_CRYPTO_API). Defaults to False.
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger(self.__class__.__name__)
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
self.running = True
|
self.running = True
|
||||||
self.cdm = cdm
|
self.cdm = cdm
|
||||||
self.adb = adb
|
self.adb = adb
|
||||||
|
|
||||||
# https://github.com/hyugogirubato/KeyDive/issues/38#issuecomment-2411932679
|
|
||||||
# Flag to skip predefined functions based on the vendor's API level
|
# 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.skip = skip
|
||||||
|
|
||||||
# Load the hook script and prepare for injection
|
# Load the hook script with relevant data and prepare for injection
|
||||||
self.functions = functions
|
self.functions = functions
|
||||||
self.script = self.__prepare_hook_script()
|
self.script = self.__prepare_hook_script()
|
||||||
self.logger.info('Script loaded successfully')
|
self.logger.info("Hook script prepared successfully")
|
||||||
|
|
||||||
def __prepare_hook_script(self) -> str:
|
def __prepare_hook_script(self) -> str:
|
||||||
"""
|
"""
|
||||||
Prepares the hook script content by injecting the library-specific scripts.
|
Prepares the hook script by injecting library-specific data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The prepared script content.
|
str: The finalized hook script content with placeholders replaced.
|
||||||
"""
|
"""
|
||||||
content = Path(__file__).with_name('keydive.js').read_text(encoding='utf-8')
|
# Read the base JavaScript template file
|
||||||
|
content = 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 = self.__prepare_symbols(self.functions)
|
||||||
|
|
||||||
# Replace placeholders in script template
|
# Define the placeholder replacements
|
||||||
replacements = {
|
replacements = {
|
||||||
'${OEM_CRYPTO_API}': json.dumps(list(OEM_CRYPTO_API)),
|
"${OEM_CRYPTO_API}": json.dumps(list(OEM_CRYPTO_API)),
|
||||||
'${NATIVE_C_API}': json.dumps(list(NATIVE_C_API)),
|
"${NATIVE_C_API}": json.dumps(list(NATIVE_C_API)),
|
||||||
'${SYMBOLS}': json.dumps(symbols),
|
"${SYMBOLS}": json.dumps(symbols),
|
||||||
'${SKIP}': str(self.skip)
|
"${SKIP}": str(self.skip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Replace placeholders in the script content
|
||||||
for placeholder, value in replacements.items():
|
for placeholder, value in replacements.items():
|
||||||
content = content.replace(placeholder, value)
|
content = content.replace(placeholder, value, 1)
|
||||||
|
|
||||||
return content
|
return content
|
||||||
|
|
||||||
def __prepare_symbols(self, path: Path) -> list:
|
def __prepare_symbols(self, path: Path) -> list:
|
||||||
"""
|
"""
|
||||||
Parses the provided XML functions file to select relevant functions.
|
Extracts relevant functions from a Ghidra XML file.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
path (Path): Path to Ghidra XML functions file.
|
path (Path): Path to the Ghidra XML functions file.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list: List of selected functions as dictionaries.
|
list: List of selected functions as dictionaries.
|
||||||
|
@ -81,70 +85,83 @@ class Core:
|
||||||
FileNotFoundError: If the functions file is not found.
|
FileNotFoundError: If the functions file is not found.
|
||||||
ValueError: If functions extraction fails.
|
ValueError: If functions extraction fails.
|
||||||
"""
|
"""
|
||||||
|
# Return an empty list if no path is provided
|
||||||
if not path:
|
if not path:
|
||||||
return []
|
return []
|
||||||
elif not path.is_file():
|
|
||||||
raise FileNotFoundError('Functions file not found')
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
program = xmltodict.parse(path.read_bytes())['PROGRAM']
|
# Parse the XML file and extract program data
|
||||||
addr_base = int(program['@IMAGE_BASE'], 16)
|
program = xmltodict.parse(path.read_bytes())["PROGRAM"]
|
||||||
functions = program['FUNCTIONS']['FUNCTION']
|
addr_base = int(program["@IMAGE_BASE"], 16) # Base address for function addresses
|
||||||
|
functions = program["FUNCTIONS"]["FUNCTION"] # List of functions in the XML
|
||||||
|
|
||||||
# Find a target function from a predefined list
|
# Identify a target function from the predefined OEM_CRYPTO_API list (if not skipped)
|
||||||
target = None if self.skip else next((f['@NAME'] for f in functions if f['@NAME'] in OEM_CRYPTO_API), None)
|
target = next((f["@NAME"] for f in functions if f["@NAME"] in OEM_CRYPTO_API and not self.skip), None)
|
||||||
|
|
||||||
# Extract relevant functions
|
# Prepare a dictionary to store selected functions
|
||||||
selected = {}
|
selected = {}
|
||||||
for func in functions:
|
for func in functions:
|
||||||
name = func['@NAME']
|
name = func["@NAME"] # Function name
|
||||||
args = len(func.get('REGISTER_VAR', []))
|
args = len(func.get("REGISTER_VAR", [])) # Number of arguments
|
||||||
|
|
||||||
# Add function if it matches specific criteria
|
"""
|
||||||
|
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 (
|
if name not in selected and (
|
||||||
name == target
|
name == target
|
||||||
or any(True if self.skip else keyword in name for keyword in CDM_FUNCTION_API)
|
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)
|
or (not target and re.match(r"^[a-z]+$", name) and args >= 6)
|
||||||
):
|
):
|
||||||
selected[name] = {
|
selected[name] = {
|
||||||
'type': 'function',
|
"type": "function",
|
||||||
'name': name,
|
"name": name,
|
||||||
'address': hex(int(func['@ENTRY_POINT'], 16) - addr_base)
|
"address": hex(int(func["@ENTRY_POINT"], 16) - addr_base) # Calculate relative address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Return the list of selected functions
|
||||||
return list(selected.values())
|
return list(selected.values())
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise FileNotFoundError(f"Functions file not found: {path}") from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError('Failed to extract functions from Ghidra') from e
|
raise ValueError("Failed to extract functions from Ghidra XML file") from e
|
||||||
|
|
||||||
def __process_message(self, message: dict, data: bytes) -> None:
|
def __process_message(self, message: dict, data: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
Handles messages received from the Frida script.
|
Handles messages received from the Frida script.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
message (dict): The message payload.
|
message (dict): The message payload.
|
||||||
data (bytes): The raw data associated with the message.
|
data (bytes): The raw data associated with the message.
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger('Script')
|
logger = logging.getLogger("Script")
|
||||||
level = message.get('payload')
|
level = message.get("payload")
|
||||||
|
|
||||||
if isinstance(level, int):
|
if isinstance(level, int):
|
||||||
# Process logging messages from Frida script
|
# Log the message based on its severity level
|
||||||
logger.log(level=level, msg=data.decode('utf-8'))
|
logger.log(level=level, msg=data.decode("utf-8"))
|
||||||
if level in (logging.FATAL, logging.CRITICAL):
|
if level in (logging.FATAL, logging.CRITICAL):
|
||||||
self.running = False
|
self.running = False # Stop the process on critical errors
|
||||||
elif isinstance(level, dict) and 'private_key' in level:
|
elif isinstance(level, dict) and "private_key" in level:
|
||||||
self.cdm.set_private_key(data=data, name=level['private_key'])
|
# Set the private key in the DRM handler
|
||||||
elif level == 'challenge':
|
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)
|
self.cdm.set_challenge(data=data)
|
||||||
elif level == 'device_id':
|
elif level == "device_id":
|
||||||
|
# Set the device ID in the DRM handler
|
||||||
self.cdm.set_device_id(data)
|
self.cdm.set_device_id(data)
|
||||||
elif level == 'keybox':
|
elif level == "keybox":
|
||||||
|
# Set the keybox data in the DRM handler
|
||||||
self.cdm.set_keybox(data)
|
self.cdm.set_keybox(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 the specified process.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
pid (int): The process ID to hook.
|
pid (int): The process ID to hook.
|
||||||
vendor (Vendor): Instance of Vendor class representing the vendor information.
|
vendor (Vendor): Instance of Vendor class representing the vendor information.
|
||||||
timeout (int, optional): Timeout for attaching to the process. Defaults to 0.
|
timeout (int, optional): Timeout for attaching to the process. Defaults to 0.
|
||||||
|
@ -153,38 +170,57 @@ class Core:
|
||||||
bool: True if the process was successfully hooked, otherwise False.
|
bool: True if the process was successfully hooked, otherwise False.
|
||||||
"""
|
"""
|
||||||
try:
|
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)
|
session: Session = self.adb.device.attach(pid, persist_timeout=timeout)
|
||||||
except frida.ServerNotRunningError as e:
|
except frida.ServerNotRunningError as e:
|
||||||
raise EnvironmentError('Frida server is not running') from e
|
# Handle the case where the Frida server is not running on the device.
|
||||||
|
raise EnvironmentError("Frida server is not running") from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
# Log other exceptions and return False to indicate failure.
|
||||||
self.logger.error(e)
|
self.logger.error(e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Define a callback to handle when the process is destroyed.
|
||||||
def __process_destroyed() -> None:
|
def __process_destroyed() -> None:
|
||||||
session.detach()
|
session.detach()
|
||||||
|
|
||||||
|
# Create a Frida script object using the prepared script content.
|
||||||
script: Script = session.create_script(self.script)
|
script: Script = session.create_script(self.script)
|
||||||
script.on('message', self.__process_message)
|
script.on("message", self.__process_message)
|
||||||
script.on('destroyed', __process_destroyed)
|
script.on("destroyed", __process_destroyed)
|
||||||
script.load()
|
script.load()
|
||||||
|
|
||||||
|
# Fetch a list of libraries loaded by the target process.
|
||||||
libraries = script.exports_sync.getlibraries()
|
libraries = script.exports_sync.getlibraries()
|
||||||
library = next((l for l in libraries if re.match(vendor.pattern, l['name'])), None)
|
library = next((l for l in libraries if re.match(vendor.pattern, l["name"])), None)
|
||||||
|
|
||||||
if library:
|
if library:
|
||||||
self.logger.info('Library: %s (%s)', library['name'], library['path'])
|
# Log information about the library if it is found.
|
||||||
|
self.logger.info("Library: %s (%s)", library["name"], library["path"])
|
||||||
|
|
||||||
# Check if Ghidra XML functions loaded
|
# Retrieve and log the version of the Frida server.
|
||||||
if vendor.oem > 17 and not self.functions:
|
version = script.exports_sync.getversion()
|
||||||
self.logger.warning('For OEM API > 17, specifying "functions" is required, refer to https://github.com/hyugogirubato/KeyDive/blob/main/docs/FUNCTIONS.md')
|
self.logger.debug(f"Server: %s", version)
|
||||||
elif vendor.oem < 18 and self.functions:
|
|
||||||
self.logger.warning('The "functions" attribute is deprecated for OEM API < 18')
|
|
||||||
|
|
||||||
return script.exports_sync.hooklibrary(library['name'])
|
# Determine if the Frida server version is older than 16.6.0.
|
||||||
|
code = tuple(map(int, version.split(".")))
|
||||||
|
minimum = code[0] < 16 or (code == 16 and code[1] < 6)
|
||||||
|
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
return script.exports_sync.hooklibrary(library["name"])
|
||||||
|
|
||||||
|
# Unload the script if the target library is not found.
|
||||||
script.unload()
|
script.unload()
|
||||||
self.logger.warning('Library not found: %s' % vendor.pattern)
|
self.logger.warning("Library not found: %s" % vendor.pattern)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('Core',)
|
__all__ = ("Core",)
|
||||||
|
|
|
@ -5,21 +5,20 @@ import logging
|
||||||
from json.encoder import encode_basestring_ascii
|
from json.encoder import encode_basestring_ascii
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def bytes2int(value: bytes, byteorder: Literal['big', 'little'] = 'big', signed: bool = False) -> int:
|
def bytes2int(value: bytes, byteorder: Literal["big", "little"] = "big", signed: bool = False) -> int:
|
||||||
"""
|
"""
|
||||||
Convert bytes to an integer.
|
Convert a byte sequence to an integer.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
value (bytes): The byte sequence to convert.
|
value (bytes): The byte sequence to convert.
|
||||||
byteorder (Literal['big', 'little'], optional): The byte order for conversion. Defaults to 'big'.
|
byteorder (str, optional): Byte order for conversion. 'big' or 'little'. Defaults to 'big'.
|
||||||
signed (bool, optional): Indicates if the integer is signed. Defaults to False.
|
signed (bool, optional): Whether the integer is signed. Defaults to False.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int: The converted integer.
|
int: The integer representation of the byte sequence.
|
||||||
"""
|
"""
|
||||||
return int.from_bytes(value, byteorder=byteorder, signed=signed)
|
return int.from_bytes(value, byteorder=byteorder, signed=signed)
|
||||||
|
|
||||||
|
@ -31,12 +30,7 @@ class Keybox:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
"""
|
"""
|
||||||
Initializes the Keybox object, setting up a logger and containers for device IDs and keyboxes.
|
Initializes the Keybox object, setting up logger and containers for device IDs and keyboxes.
|
||||||
|
|
||||||
Attributes:
|
|
||||||
logger (Logger): Logger instance for logging messages.
|
|
||||||
device_id (list[bytes]): List of unique device IDs (32 bytes each).
|
|
||||||
keybox (dict[bytes, bytes]): Dictionary mapping device IDs to their respective keybox data.
|
|
||||||
"""
|
"""
|
||||||
self.logger = logging.getLogger(self.__class__.__name__)
|
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
|
# https://github.com/kaltura/kaltura-device-info-android/blob/master/app/src/main/java/com/kaltura/kalturadeviceinfo/MainActivity.java#L203
|
||||||
|
@ -47,7 +41,7 @@ class Keybox:
|
||||||
"""
|
"""
|
||||||
Set the device ID from the provided data.
|
Set the device ID from the provided data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (bytes): The device ID, expected to be 32 bytes long.
|
data (bytes): The device ID, expected to be 32 bytes long.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
@ -55,19 +49,22 @@ class Keybox:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
size = len(data)
|
size = len(data)
|
||||||
assert size == 32, f'Invalid keybox length: {size}. Should be 32 bytes'
|
# 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:
|
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.logger.info("Receive device id: \n\n%s\n", encode_basestring_ascii(data.decode("utf-8")))
|
||||||
self.device_id.append(data)
|
self.device_id.append(data)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug('Failed to set device id: %s', e)
|
self.logger.debug("Failed to set device id: %s", e)
|
||||||
|
|
||||||
def set_keybox(self, data: bytes) -> None:
|
def set_keybox(self, data: bytes) -> None:
|
||||||
"""
|
"""
|
||||||
Set the keybox from the provided data.
|
Set the keybox from the provided data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (bytes): The keybox data, expected to be either 128 or 132 bytes long.
|
data (bytes): The keybox data, expected to be either 128 or 132 bytes long.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
|
@ -76,68 +73,74 @@ class Keybox:
|
||||||
# https://github.com/zybpp/Python/tree/master/Python/keybox
|
# https://github.com/zybpp/Python/tree/master/Python/keybox
|
||||||
try:
|
try:
|
||||||
size = len(data)
|
size = len(data)
|
||||||
assert size in (128, 132), f'Invalid keybox length: {size}. Should be 128 or 132 bytes'
|
# Validate the keybox size (128 or 132 bytes)
|
||||||
|
assert size in (128, 132), f"Invalid keybox length: {size}. Should be 128 or 132 bytes"
|
||||||
|
|
||||||
if size == 132:
|
# Validate the QSEE-style keybox end
|
||||||
assert data[128:132] == b"LVL1", 'QSEE-style keybox must end with bytes "LVL1"'
|
assert size == 128 or data[128:132] == b"LVL1", "QSEE-style keybox must end with bytes 'LVL1'"
|
||||||
|
|
||||||
assert data[120:124] == b"kbox", 'Invalid keybox magic'
|
# Validate the keybox magic (should be 'kbox')
|
||||||
|
assert data[120:124] == b"kbox", "Invalid keybox magic"
|
||||||
|
|
||||||
device_id = data[0:32]
|
device_id = data[0:32] # Extract the device ID from the first 32 bytes
|
||||||
|
|
||||||
# Retrieve structured keybox info and log it
|
# Retrieve and log the structured keybox information
|
||||||
infos = self.__keybox_info(data)
|
infos = self.__keybox_info(data)
|
||||||
encrypted = infos['flags'] > 10
|
encrypted = infos["flags"] > 10 # Check if the keybox is encrypted
|
||||||
self.set_device_id(data=device_id)
|
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:
|
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))
|
self.logger.info("Receive keybox: \n\n%s\n", json.dumps(infos, indent=2))
|
||||||
|
|
||||||
# Warn if flags indicate encryption, which requires an unencrypted device token
|
# Warn if keybox is encrypted and interception of plaintext device token is needed
|
||||||
if encrypted:
|
if encrypted:
|
||||||
self.logger.warning('Keybox contains encrypted data. Interception of plaintext device token is needed')
|
self.logger.warning("Keybox contains encrypted data. Interception of plaintext device token is needed")
|
||||||
|
|
||||||
# Store or update the keybox for the device if it's not already saved
|
# 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:
|
if (device_id in self.keybox and not encrypted) or device_id not in self.keybox:
|
||||||
self.keybox[device_id] = (data, encrypted)
|
self.keybox[device_id] = (data, encrypted)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.debug('Failed to set keybox: %s', e)
|
self.logger.debug("Failed to set keybox: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def __keybox_info(data: bytes) -> dict:
|
def __keybox_info(data: bytes) -> dict:
|
||||||
"""
|
"""
|
||||||
Extract keybox information from the provided data.
|
Extract keybox information from the provided data.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
data (bytes): The keybox data.
|
data (bytes): The keybox data.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: A dictionary containing extracted keybox information.
|
dict: A dictionary containing extracted keybox information.
|
||||||
"""
|
"""
|
||||||
# https://github.com/wvdumper/dumper/blob/main/Helpers/Keybox.py#L51
|
# https://github.com/wvdumper/dumper/blob/main/Helpers/Keybox.py#L51
|
||||||
|
|
||||||
|
# Extract device-specific information from the keybox data
|
||||||
device_token = data[48:120]
|
device_token = data[48:120]
|
||||||
|
|
||||||
|
# Prepare the keybox content dictionary
|
||||||
content = {
|
content = {
|
||||||
'device_id': data[0:32].decode('utf-8'), # Device's unique identifier (32 bytes)
|
"device_id": data[0:32].decode("utf-8"), # Device's unique identifier (32 bytes)
|
||||||
'device_key': data[32:48], # Device-specific cryptographic key (16 bytes)
|
"device_key": data[32:48], # Device cryptographic key (16 bytes)
|
||||||
'device_token': device_token, # Token used for device authentication (72 bytes)
|
"device_token": device_token, # Token for device authentication (72 bytes)
|
||||||
'keybox_tag': data[120:124].decode('utf-8'), # Magic tag indicating keybox format (4 bytes)
|
"keybox_tag": data[120:124].decode("utf-8"), # Magic tag (4 bytes)
|
||||||
'crc32': bytes2int(data[124:128]), # CRC32 checksum for data integrity verification (4 bytes)
|
"crc32": bytes2int(data[124:128]), # CRC32 checksum (4 bytes)
|
||||||
'level_tag': data[128:132].decode('utf-8') or None, # Optional tag indicating keybox level (4 bytes).
|
"level_tag": data[128:132].decode("utf-8") or None, # Optional level tag (4 bytes)
|
||||||
|
|
||||||
# Additional metadata parsed from the device token (Bytes 48–120).
|
# Extract metadata from the device token (Bytes 48–120)
|
||||||
'flags': bytes2int(device_token[0:4]), # Flags indicating specific device capabilities (4 bytes).
|
"flags": bytes2int(device_token[0:4]), # Device flags (4 bytes)
|
||||||
'system_id': bytes2int(device_token[4:8]), # System identifier (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)
|
||||||
# Provisioning ID, encrypted and derived from the unique ID in the system.
|
"encrypted_bits": device_token[24:72] # Encrypted device-specific information (48 bytes)
|
||||||
'provisioning_id': UUID(bytes_le=device_token[8:24]), # Provisioning UUID (16 bytes).
|
|
||||||
|
|
||||||
# Encrypted bits containing device key, key hash, and additional flags.
|
|
||||||
'encrypted_bits': device_token[24:72] ## Encrypted device-specific information (48 bytes).
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Encode certain fields in base64 and convert UUIDs to string
|
# 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 {
|
return {
|
||||||
k: base64.b64encode(v).decode('utf-8') if isinstance(v, bytes) else str(v) if isinstance(v, UUID) else v
|
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()
|
for k, v in content.items()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -145,24 +148,33 @@ class Keybox:
|
||||||
"""
|
"""
|
||||||
Export the keybox data to a file in the specified parent directory.
|
Export the keybox data to a file in the specified parent directory.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
parent (Path): The parent directory where the keybox data will be saved.
|
parent (Path): The parent directory where the keybox data will be saved.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if any keybox were exported, otherwise False.
|
bool: True if any keybox were exported, otherwise False.
|
||||||
"""
|
"""
|
||||||
|
# Find matching keyboxes based on the device_id
|
||||||
keys = self.device_id & self.keybox.keys()
|
keys = self.device_id & self.keybox.keys()
|
||||||
|
|
||||||
for k in keys:
|
for k in keys:
|
||||||
# Prepare target directory and export file path
|
# Create the parent directory if it doesn't exist
|
||||||
parent.mkdir(parents=True, exist_ok=True)
|
parent.mkdir(parents=True, exist_ok=True)
|
||||||
path_keybox_bin = parent / f"keybox.{'enc' if self.keybox[k][1] else 'bin'}"
|
|
||||||
|
# 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])
|
path_keybox_bin.write_bytes(self.keybox[k][0])
|
||||||
|
|
||||||
|
# Log export status based on whether the keybox is encrypted
|
||||||
if self.keybox[k][1]:
|
if self.keybox[k][1]:
|
||||||
self.logger.warning('Exported encrypted keybox: %s', path_keybox_bin)
|
self.logger.warning("Exported encrypted keybox: %s", path_keybox_bin)
|
||||||
else:
|
else:
|
||||||
self.logger.info('Exported keybox: %s', path_keybox_bin)
|
self.logger.info("Exported keybox: %s", path_keybox_bin)
|
||||||
|
|
||||||
|
# Return True if any keyboxes were exported, otherwise False
|
||||||
return len(keys) > 0
|
return len(keys) > 0
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('Keybox',)
|
__all__ = ("Keybox",)
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Date: 2025-01-11
|
* Date: 2025-01-18
|
||||||
* Description: DRM key extraction for research and educational purposes.
|
* Description: DRM key extraction for research and educational purposes.
|
||||||
* Source: https://github.com/hyugogirubato/KeyDive
|
* Source: https://github.com/hyugogirubato/KeyDive
|
||||||
*/
|
*/
|
||||||
|
@ -65,6 +65,8 @@ const print = (level, message) => {
|
||||||
send(level, message);
|
send(level, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getVersion = () => Frida.version;
|
||||||
|
|
||||||
|
|
||||||
// @Utils
|
// @Utils
|
||||||
const getLibraries = (name) => {
|
const getLibraries = (name) => {
|
||||||
|
@ -85,7 +87,20 @@ const getLibrary = (name) => {
|
||||||
|
|
||||||
const getFunctions = (library) => {
|
const getFunctions = (library) => {
|
||||||
try {
|
try {
|
||||||
return library.enumerateExports();
|
// https://frida.re/news/2025/01/09/frida-16-6-0-released/
|
||||||
|
const functions = library.enumerateSymbols().map(item => ({
|
||||||
|
type: item.type,
|
||||||
|
name: item.name,
|
||||||
|
address: item.address
|
||||||
|
}));
|
||||||
|
|
||||||
|
library.enumerateExports().forEach(item => {
|
||||||
|
if (!functions.includes(item)) {
|
||||||
|
functions.push(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return functions;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print(Level.CRITICAL, e.message);
|
print(Level.CRITICAL, e.message);
|
||||||
return [];
|
return [];
|
||||||
|
@ -453,6 +468,7 @@ const hookLibrary = (name) => {
|
||||||
|
|
||||||
// RPC interfaces exposed to external calls.
|
// RPC interfaces exposed to external calls.
|
||||||
rpc.exports = {
|
rpc.exports = {
|
||||||
|
getversion: getVersion,
|
||||||
getlibraries: getLibraries,
|
getlibraries: getLibraries,
|
||||||
hooklibrary: hookLibrary
|
hooklibrary: hookLibrary
|
||||||
};
|
};
|
||||||
|
|
|
@ -7,11 +7,11 @@ class Vendor:
|
||||||
"""
|
"""
|
||||||
Initializes a Vendor instance.
|
Initializes a Vendor instance.
|
||||||
|
|
||||||
Args:
|
Parameters:
|
||||||
sdk (int): Minimum SDK version required.
|
sdk (int): Minimum SDK version required by the vendor.
|
||||||
oem (int): OEM identifier.
|
oem (int): OEM identifier for the vendor.
|
||||||
version (str): Version of the vendor.
|
version (str): Version of the vendor.
|
||||||
pattern (str): Name of the vendor.
|
pattern (str): Name pattern of the vendor.
|
||||||
"""
|
"""
|
||||||
self.sdk = sdk
|
self.sdk = sdk
|
||||||
self.oem = oem
|
self.oem = oem
|
||||||
|
@ -23,12 +23,12 @@ class Vendor:
|
||||||
Returns a string representation of the Vendor instance.
|
Returns a string representation of the Vendor instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: String representation of the Vendor instance.
|
str: String representation of the Vendor instance with its attributes.
|
||||||
"""
|
"""
|
||||||
return '{name}({items})'.format(
|
return "{name}({items})".format(
|
||||||
name=self.__class__.__name__,
|
name=self.__class__.__name__,
|
||||||
items=', '.join([f'{k}={repr(v)}' for k, v in self.__dict__.items()])
|
items=", ".join([f"{k}={repr(v)}" for k, v in self.__dict__.items()])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ('Vendor',)
|
__all__ = ("Vendor",)
|
||||||
|
|
|
@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "keydive"
|
name = "keydive"
|
||||||
version = "2.1.5"
|
version = "2.2.0"
|
||||||
description = "Extract Widevine L3 keys from Android devices effortlessly, spanning multiple Android versions for DRM research and education."
|
description = "Extract Widevine L3 keys from Android devices effortlessly, spanning multiple Android versions for DRM research and education."
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"]
|
authors = ["hyugogirubato <65763543+hyugogirubato@users.noreply.github.com>"]
|
||||||
|
@ -36,7 +36,7 @@ include = [
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.8"
|
python = "^3.8"
|
||||||
coloredlogs = "^15.0.1"
|
coloredlogs = "^15.0.1"
|
||||||
frida = "^16.5.6"
|
frida = "^16.6.0"
|
||||||
pathlib = "^1.0.1"
|
pathlib = "^1.0.1"
|
||||||
pycryptodomex = "^3.21.0"
|
pycryptodomex = "^3.21.0"
|
||||||
pywidevine = "^1.8.0"
|
pywidevine = "^1.8.0"
|
||||||
|
|
Loading…
Reference in New Issue