Created
November 25, 2025 13:47
-
-
Save ankurpandeyvns/3c90a7012b17be0e66121653e63bd93f to your computer and use it in GitHub Desktop.
Excitel PPC router config backup/restore tool. Decrypts/encrypts config files via web interface. Reverse-engineered XOR + AES-ECB encryption with null key. Supports round-trip editing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| ZTE ZXIC Router Configuration Backup/Restore Tool | |
| ================================================== | |
| This tool allows you to backup, decrypt, encrypt, and restore configuration | |
| files from ZTE ZXIC series routers (tested on ZXIC 2K05X). | |
| ENCRYPTION DETAILS: | |
| ------------------ | |
| ZTE router configs use a multi-layer encryption scheme: | |
| 1. Web Backup File Structure (router_config_backup.bin): | |
| ┌─────────────────────────────────────────────────────────────────┐ | |
| │ 128 bytes: ZTE Header │ | |
| │ - Magic: 99999999 44444444 55555555 aaaaaaaa │ | |
| │ - Encryption flags and payload size │ | |
| ├─────────────────────────────────────────────────────────────────┤ | |
| │ XOR Encrypted Payload (using 89-byte key): │ | |
| │ ┌─────────────────────────────────────────────────────────┐ │ | |
| │ │ 128 bytes: PPCT Header │ │ | |
| │ │ - Magic "PPCT" at offset 0x0C │ │ | |
| │ │ - 32-byte AES-encrypted prefix at 0x58-0x78 │ │ | |
| │ │ - CRC[0:8] at 0x78-0x80 (boundary marker) │ │ | |
| │ ├─────────────────────────────────────────────────────────┤ │ | |
| │ │ 8 bytes: CRC[8:16] (completes boundary CRC pattern) │ │ | |
| │ ├─────────────────────────────────────────────────────────┤ │ | |
| │ │ AES-ECB encrypted data with: │ │ | |
| │ │ - 12-byte chunk markers every ~64KB │ │ | |
| │ │ - 16-byte CRC patterns at various positions │ │ | |
| │ └─────────────────────────────────────────────────────────┘ │ | |
| └─────────────────────────────────────────────────────────────────┘ | |
| 2. Internal Config File Structure (db_user_cfg.xml from FTP): | |
| - 68-byte header (magic: 01020304 + sizes) | |
| - 4-byte prefix | |
| - AES-ECB encrypted data with 16-byte CRC blocks | |
| 3. Encryption Keys: | |
| - XOR Key (89 bytes): *&(*H65GFRUY6KH53%#74BUG^%^RFIOO*&*^&^RRU6YOK8PE(&(#TI_+7(U9(7!U(HF*(ET6FGHKDIO8E@67!R#@# | |
| - AES Key (16 bytes): All zeros (0x00 * 16) - yes, really! | |
| - CRC Pattern (16 bytes): b1ca8498cc0d83389e5555c03011ecdc | |
| 4. Decryption Process (Web Backup): | |
| a) Skip 128-byte ZTE header | |
| b) XOR decrypt payload with XOR_KEY | |
| c) Extract 32-byte encrypted prefix from PPCT header (offset 0x58-0x78) | |
| d) Skip 8-byte CRC suffix after PPCT (CRC[8:16]) | |
| e) Remove 12-byte chunk size markers (every ~64KB) | |
| f) Remove 16-byte CRC pattern blocks | |
| g) Combine prefix + cleaned data | |
| h) AES-ECB decrypt with zero key | |
| i) Extract XML content (<DB>...</DB>) | |
| 5. Round-Trip Encryption: | |
| When re-encrypting with a reference file, the tool produces a byte-for-byte | |
| identical copy by preserving the exact structure (CRC positions, chunk | |
| markers, PPCT header layout). | |
| USAGE: | |
| ------ | |
| # Backup and decrypt config | |
| python3 zte_config_tool.py backup -o config_backup.xml | |
| # Backup without decryption (raw encrypted file) | |
| python3 zte_config_tool.py backup --raw -o config_backup.bin | |
| # Decrypt an existing backup file | |
| python3 zte_config_tool.py decrypt -i config_backup.bin -o config.xml | |
| # Encrypt XML and restore to router (CAUTION: will restart router) | |
| python3 zte_config_tool.py restore -i config.xml | |
| # Use custom router credentials | |
| python3 zte_config_tool.py backup -H 192.168.100.1 -u admin -p password -o backup.xml | |
| REQUIREMENTS: | |
| ------------ | |
| pip install pycryptodome | |
| (uses urllib from standard library for HTTP - no requests needed) | |
| AUTHOR: Generated with Claude Code | |
| DATE: 2025 | |
| TESTED ON: ZTE ZXIC 2K05X (Firmware V1.1.18_206L_206) | |
| """ | |
| import argparse | |
| import struct | |
| import sys | |
| import re | |
| from typing import Optional | |
| from io import BytesIO | |
| try: | |
| from Crypto.Cipher import AES | |
| except ImportError: | |
| print("Error: pycryptodome is required. Install with: pip install pycryptodome") | |
| sys.exit(1) | |
| import urllib.request | |
| import urllib.parse | |
| import urllib.error | |
| import http.cookiejar | |
| import socket | |
| import select | |
| import time as time_module | |
| # ============================================================================= | |
| # ENCRYPTION CONSTANTS | |
| # ============================================================================= | |
| # 89-byte XOR key found in cspd binary | |
| # Used for first layer of encryption on web backup files | |
| XOR_KEY = b'*&(*H65GFRUY6KH53%#74BUG^%^RFIOO*&*^&^RRU6YOK8PE(&(#TI_+7(U9(7!U(HF*(ET6FGHKDIO8E@67!R#@#' | |
| # AES-128 key (all zeros) | |
| # Used for AES-ECB encryption of config data | |
| AES_KEY = bytes(16) | |
| # CRC checksum block pattern | |
| # This 16-byte pattern is inserted at various positions in the encrypted data | |
| # as integrity checks. Must be removed before AES decryption. | |
| CRC_PATTERN = bytes.fromhex('b1ca8498cc0d83389e5555c03011ecdc') | |
| # ZTE backup file header magic bytes | |
| ZTE_HEADER_MAGIC = bytes.fromhex('999999994444444455555555aaaaaaaa') | |
| # PPCT header magic | |
| PPCT_MAGIC = b'PPCT' | |
| # Internal config header magic | |
| INTERNAL_MAGIC = bytes.fromhex('01020304') | |
| # ============================================================================= | |
| # ENCRYPTION/DECRYPTION FUNCTIONS | |
| # ============================================================================= | |
| def xor_data(data: bytes, key: bytes) -> bytes: | |
| """ | |
| XOR encrypt/decrypt data with a repeating key. | |
| Args: | |
| data: Input bytes to encrypt/decrypt | |
| key: XOR key (will be repeated if shorter than data) | |
| Returns: | |
| XOR'd output bytes | |
| """ | |
| result = bytearray() | |
| key_len = len(key) | |
| for i, byte in enumerate(data): | |
| result.append(byte ^ key[i % key_len]) | |
| return bytes(result) | |
| def remove_chunk_markers(data: bytes) -> bytes: | |
| """ | |
| Remove chunk size markers from encrypted data. | |
| The router splits the encrypted config into ~64KB chunks, each preceded | |
| by a 12-byte marker containing the chunk size. These markers must be | |
| removed before decryption. | |
| Marker format (12 bytes): | |
| - 4 bytes: chunk size (big-endian, e.g., 0x00010000 = 65536) | |
| - 4 bytes: chunk size repeated | |
| - 4 bytes: offset counter (increments by 12 for each marker) | |
| Args: | |
| data: Encrypted data containing chunk markers | |
| Returns: | |
| Data with chunk markers removed | |
| """ | |
| result = bytearray() | |
| i = 0 | |
| while i < len(data): | |
| # Check for chunk marker pattern: size + size + counter | |
| if i + 12 <= len(data): | |
| # Read potential marker | |
| size1 = int.from_bytes(data[i:i+4], 'big') | |
| size2 = int.from_bytes(data[i+4:i+8], 'big') | |
| # Valid marker: both sizes match and are reasonable (100 - 100000) | |
| if size1 == size2 and 100 < size1 < 100000: | |
| # Skip 12-byte marker | |
| i += 12 | |
| continue | |
| result.append(data[i]) | |
| i += 1 | |
| return bytes(result) | |
| def remove_crc_blocks(data: bytes, pattern: bytes = CRC_PATTERN) -> bytes: | |
| """ | |
| Remove CRC checksum blocks from encrypted data. | |
| The router inserts 16-byte CRC patterns at various positions in the | |
| encrypted config data. These must be removed before AES decryption. | |
| Args: | |
| data: Encrypted data containing CRC blocks | |
| pattern: The 16-byte CRC pattern to remove | |
| Returns: | |
| Data with CRC blocks removed | |
| """ | |
| result = bytearray() | |
| i = 0 | |
| while i < len(data): | |
| if i + 16 <= len(data) and data[i:i+16] == pattern: | |
| # Skip the CRC pattern | |
| i += 16 | |
| else: | |
| result.append(data[i]) | |
| i += 1 | |
| return bytes(result) | |
| def aes_decrypt(data: bytes, key: bytes = AES_KEY) -> bytes: | |
| """ | |
| AES-ECB decrypt data. | |
| Args: | |
| data: Encrypted data (must be multiple of 16 bytes) | |
| key: 16-byte AES key (default: all zeros) | |
| Returns: | |
| Decrypted data | |
| """ | |
| # Pad to 16-byte boundary if needed | |
| pad_len = (16 - len(data) % 16) % 16 | |
| if pad_len: | |
| data = data + bytes(pad_len) | |
| cipher = AES.new(key, AES.MODE_ECB) | |
| return cipher.decrypt(data) | |
| def decrypt_web_backup(data: bytes, raw: bool = False) -> bytes: | |
| """ | |
| Decrypt a web backup config file (downloaded via browser). | |
| Structure: | |
| - 128 bytes: ZTE header | |
| - Remaining: XOR encrypted payload containing: | |
| - 128 bytes: PPCT header (includes 32-byte encrypted prefix at 0x58-0x78) | |
| - 8 bytes: CRC suffix (completes CRC pattern at PPCT/data boundary) | |
| - Remaining: AES encrypted data with chunk markers and CRC blocks | |
| The PPCT header at offset 0x58-0x78 contains the first 32 bytes of encrypted | |
| XML data. The last 8 bytes of PPCT (0x78-0x80) are CRC[0:8], and the first | |
| 8 bytes after PPCT are CRC[8:16], forming a complete CRC pattern at the boundary. | |
| Args: | |
| data: Raw backup file contents | |
| raw: If True, return full decrypted content (for re-encryption). | |
| If False, return just the XML portion. | |
| Returns: | |
| Decrypted content (full or XML-only based on raw flag) | |
| Raises: | |
| ValueError: If file format is invalid | |
| """ | |
| if len(data) < 256: | |
| raise ValueError("File too small to be a valid backup") | |
| # Verify ZTE header | |
| if data[:16] != ZTE_HEADER_MAGIC: | |
| raise ValueError(f"Invalid ZTE header. Expected {ZTE_HEADER_MAGIC.hex()}, got {data[:16].hex()}") | |
| # Step 1: XOR decrypt payload (skip 128-byte ZTE header) | |
| xor_decrypted = xor_data(data[128:], XOR_KEY) | |
| # Verify PPCT header | |
| if xor_decrypted[12:16] != PPCT_MAGIC: | |
| raise ValueError(f"Invalid PPCT header. Expected {PPCT_MAGIC}, got {xor_decrypted[12:16]}") | |
| # Step 2: Extract 32-byte encrypted prefix from PPCT header (offset 0x58-0x78) | |
| # This contains the beginning of the XML (e.g., "<DB>\n<Tbl name=...") | |
| encrypted_prefix = xor_decrypted[0x58:0x78] # 32 bytes | |
| # Step 3: Get data after PPCT header, skip first 8 bytes (CRC[8:16] suffix) | |
| # The CRC pattern spans the PPCT/data boundary: PPCT[0x78:0x80]=CRC[0:8], data[0:8]=CRC[8:16] | |
| data_after_ppct = xor_decrypted[128:] | |
| encrypted_data = data_after_ppct[8:] # Skip the CRC suffix | |
| # Step 4: Remove chunk size markers (12 bytes each, every ~64KB) | |
| no_markers = remove_chunk_markers(encrypted_data) | |
| # Step 5: Remove CRC blocks (16-byte patterns) | |
| clean_data = remove_crc_blocks(no_markers) | |
| # Step 6: Combine prefix + cleaned data | |
| full_encrypted = encrypted_prefix + clean_data | |
| # Step 7: AES decrypt (align to 16 bytes) | |
| aligned_len = len(full_encrypted) - (len(full_encrypted) % 16) | |
| decrypted = aes_decrypt(full_encrypted[:aligned_len]) | |
| if raw: | |
| # Return full decrypted content for re-encryption | |
| return decrypted | |
| # Find and extract XML content | |
| xml_start = decrypted.find(b'<DB>') | |
| if xml_start == -1: | |
| # Fallback to any XML tag | |
| xml_start = decrypted.find(b'<') | |
| if xml_start == -1: | |
| raise ValueError("No XML content found in decrypted data") | |
| # Find end of XML - look for </DB> closing tag | |
| xml_end = decrypted.rfind(b'</DB>') | |
| if xml_end != -1: | |
| xml_end += 5 # Include </DB> | |
| else: | |
| # Fallback: find last > that's part of valid XML | |
| xml_end = decrypted.rfind(b'>') + 1 | |
| return decrypted[xml_start:xml_end] | |
| def decrypt_internal_config(data: bytes) -> bytes: | |
| """ | |
| Decrypt an internal config file (downloaded via FTP). | |
| Structure: | |
| - 68 bytes: Header (magic: 01020304 + sizes) | |
| - Remaining: AES encrypted data with CRC blocks | |
| Args: | |
| data: Raw config file contents from FTP | |
| Returns: | |
| Decrypted XML configuration | |
| Raises: | |
| ValueError: If file format is invalid | |
| """ | |
| if len(data) < 100: | |
| raise ValueError("File too small to be a valid config") | |
| # Verify internal config header | |
| if data[:4] != INTERNAL_MAGIC: | |
| raise ValueError(f"Invalid header. Expected {INTERNAL_MAGIC.hex()}, got {data[:4].hex()}") | |
| # Skip 68-byte header | |
| encrypted_data = data[68:] | |
| # Remove CRC blocks | |
| clean_data = remove_crc_blocks(encrypted_data) | |
| # Skip first 4 bytes and AES decrypt | |
| decrypted = aes_decrypt(clean_data[4:]) | |
| # Find and extract XML content | |
| xml_start = decrypted.find(b'<') | |
| if xml_start == -1: | |
| raise ValueError("No XML content found in decrypted data") | |
| xml_end = decrypted.rfind(b'>') + 1 | |
| return decrypted[xml_start:xml_end] | |
| def encrypt_for_restore(decrypted_data: bytes, reference_file: bytes = None) -> bytes: | |
| """ | |
| Encrypt decrypted config data for restoring to router via web interface. | |
| This creates a properly formatted backup file that can be uploaded | |
| through the router's web restore function. If a reference file is | |
| provided, the structure (CRC positions, chunk markers) will be | |
| copied from it for a 1:1 match. | |
| Args: | |
| decrypted_data: Full decrypted content (from decrypt_web_backup with raw=True) | |
| reference_file: Optional original encrypted file for structure reference | |
| Returns: | |
| Encrypted backup file ready for upload | |
| """ | |
| # Pad to 16-byte boundary for AES if needed | |
| pad_len = (16 - len(decrypted_data) % 16) % 16 | |
| padded_data = decrypted_data + bytes(pad_len) | |
| # Step 1: AES encrypt | |
| cipher = AES.new(AES_KEY, AES.MODE_ECB) | |
| aes_encrypted = cipher.encrypt(padded_data) | |
| if reference_file: | |
| # Use reference file to get exact CRC and marker positions | |
| # Pass the full AES-encrypted data (first 32 bytes go into PPCT header) | |
| return _encrypt_with_reference(aes_encrypted, reference_file) | |
| # Without reference file, build from scratch | |
| # Step 2: Add 8-byte prefix (second half of CRC pattern) | |
| prefix = CRC_PATTERN[8:16] # 9e5555c03011ecdc | |
| with_prefix = prefix + aes_encrypted[32:] # Skip first 32 bytes (goes into PPCT) | |
| # Step 3: Insert CRC blocks at intervals matching original structure | |
| # Based on analysis: CRCs at positions 40, 136, 2136, 9560, 26904, then irregular | |
| # For simplicity, insert at fixed intervals that should work | |
| crc_positions = [40, 136, 2136, 9560, 26904] | |
| # Add more CRCs at regular intervals after that | |
| pos = 26904 | |
| while pos < len(with_prefix): | |
| pos += 50000 # Roughly every 50KB | |
| if pos < len(with_prefix): | |
| crc_positions.append(pos) | |
| # Final CRC near the end | |
| if len(with_prefix) > 100: | |
| crc_positions.append(len(with_prefix) - 64) | |
| with_crc = bytearray() | |
| last_pos = 0 | |
| for crc_pos in sorted(crc_positions): | |
| if crc_pos < len(with_prefix): | |
| with_crc.extend(with_prefix[last_pos:crc_pos]) | |
| with_crc.extend(CRC_PATTERN) | |
| last_pos = crc_pos | |
| with_crc.extend(with_prefix[last_pos:]) | |
| # Step 4: Insert chunk markers every 65536 bytes | |
| chunk_size = 65536 | |
| with_markers = bytearray() | |
| counter = 84 # Starting counter value from original | |
| for i in range(0, len(with_crc), chunk_size): | |
| chunk = with_crc[i:i + chunk_size] | |
| with_markers.extend(chunk) | |
| # Insert marker after each full chunk | |
| if i + chunk_size < len(with_crc): | |
| marker = struct.pack('>I', chunk_size) # Size | |
| marker += struct.pack('>I', chunk_size) # Size repeated | |
| marker += struct.pack('>I', counter) # Counter | |
| with_markers.extend(marker) | |
| counter += 12 | |
| # Step 5: Create PPCT header (128 bytes) | |
| ppct_header = bytearray(128) | |
| ppct_header[0:4] = struct.pack('<I', 0x04030201) # Version | |
| ppct_header[12:16] = PPCT_MAGIC # "PPCT" | |
| ppct_header[16:20] = struct.pack('>I', 0x01020304) # Type | |
| # Calculate sizes | |
| data_without_crc = len(with_prefix) | |
| data_with_crc = len(bytes(with_crc)) | |
| ppct_header[24:28] = struct.pack('>I', data_without_crc) | |
| ppct_header[28:32] = struct.pack('>I', data_with_crc) | |
| # Add marker flags | |
| ppct_header[0x50:0x54] = struct.pack('>I', 0x00010000) | |
| ppct_header[0x54:0x58] = struct.pack('>I', 0x48) | |
| # Add CRC pattern at end of PPCT header | |
| ppct_header[0x70:0x78] = CRC_PATTERN[:8] | |
| # Combine PPCT header and encrypted data | |
| ppct_payload = bytes(ppct_header) + bytes(with_markers) | |
| # Step 6: XOR encrypt the payload | |
| xor_encrypted = xor_data(ppct_payload, XOR_KEY) | |
| # Step 7: Create ZTE header (128 bytes) | |
| zte_header = bytearray(128) | |
| zte_header[0:16] = ZTE_HEADER_MAGIC | |
| zte_header[0x14:0x18] = struct.pack('<I', 0x04) # Encryption flag | |
| zte_header[0x50:0x54] = struct.pack('<I', 2) # Encryption type | |
| zte_header[0x54:0x58] = struct.pack('<I', 128) # PPCT header size | |
| zte_header[0x58:0x5C] = struct.pack('<I', len(xor_encrypted)) # Payload size | |
| return bytes(zte_header) + xor_encrypted | |
| def _encrypt_with_reference(aes_encrypted: bytes, reference_file: bytes) -> bytes: | |
| """ | |
| Encrypt data using the exact structure from a reference file. | |
| For 1:1 copy, we rebuild the file with the same structure: | |
| - 32-byte prefix in PPCT header at 0x58-0x78 | |
| - CRC pattern split across PPCT/data boundary | |
| - Same CRC positions and chunk markers | |
| Args: | |
| aes_encrypted: AES-encrypted data (full, including first 32 bytes for PPCT prefix) | |
| reference_file: Original encrypted backup file | |
| Returns: | |
| Encrypted file with same structure as reference | |
| """ | |
| # Extract original structure | |
| xor_dec = xor_data(reference_file[128:], XOR_KEY) | |
| # Get original PPCT header (need to preserve most of it) | |
| orig_ppct = bytearray(xor_dec[:128]) | |
| # Split our encrypted data: first 32 bytes go into PPCT, rest goes after | |
| encrypted_prefix = aes_encrypted[:32] | |
| encrypted_rest = aes_encrypted[32:] | |
| # Original data after PPCT (includes 8-byte CRC suffix + encrypted data) | |
| orig_after_ppct = xor_dec[128:] | |
| # Skip first 8 bytes (CRC suffix) to get to actual encrypted data | |
| orig_encrypted = orig_after_ppct[8:] | |
| # Get the clean data (without markers and CRC) | |
| orig_no_markers = remove_chunk_markers(orig_encrypted) | |
| orig_no_crc = remove_crc_blocks(orig_no_markers) | |
| # Verify our data (minus 32-byte prefix) matches the expected size | |
| if len(encrypted_rest) != len(orig_no_crc): | |
| raise ValueError(f"Data size mismatch: {len(encrypted_rest)} vs {len(orig_no_crc)}") | |
| # For 1:1 match, we need to rebuild the exact structure | |
| # Strategy: iterate through orig_no_markers and replace non-CRC bytes with encrypted_rest | |
| new_no_markers = bytearray() | |
| data_idx = 0 | |
| i = 0 | |
| while i < len(orig_no_markers): | |
| if i + 16 <= len(orig_no_markers) and orig_no_markers[i:i+16] == CRC_PATTERN: | |
| # Keep CRC block | |
| new_no_markers.extend(CRC_PATTERN) | |
| i += 16 | |
| else: | |
| # Replace with our data | |
| if data_idx < len(encrypted_rest): | |
| new_no_markers.append(encrypted_rest[data_idx]) | |
| data_idx += 1 | |
| i += 1 | |
| # Now reconstruct with markers | |
| # First find all marker positions in the original encrypted data | |
| marker_positions = [] | |
| i = 0 | |
| while i < len(orig_encrypted): | |
| if i + 12 <= len(orig_encrypted): | |
| size1 = int.from_bytes(orig_encrypted[i:i+4], 'big') | |
| size2 = int.from_bytes(orig_encrypted[i+4:i+8], 'big') | |
| if size1 == size2 and 100 < size1 < 100000: | |
| marker_positions.append((i, orig_encrypted[i:i+12])) | |
| i += 12 | |
| continue | |
| i += 1 | |
| # Calculate marker positions relative to data without markers | |
| # and insert them at the same relative positions | |
| new_with_markers = bytearray() | |
| src_pos = 0 # Position in new_no_markers | |
| for orig_pos, marker_bytes in marker_positions: | |
| # Calculate how much data comes before this marker in marker-free space | |
| markers_before = sum(1 for p, _ in marker_positions if p < orig_pos) | |
| data_before_marker = orig_pos - (markers_before * 12) | |
| # Copy data up to this point | |
| if data_before_marker > src_pos: | |
| new_with_markers.extend(new_no_markers[src_pos:data_before_marker]) | |
| src_pos = data_before_marker | |
| # Insert marker | |
| new_with_markers.extend(marker_bytes) | |
| # Copy remaining data | |
| new_with_markers.extend(new_no_markers[src_pos:]) | |
| # Update PPCT header with new encrypted prefix | |
| orig_ppct[0x58:0x78] = encrypted_prefix | |
| # Rebuild: 8-byte CRC suffix (from original) + new encrypted data with markers | |
| crc_suffix = orig_after_ppct[:8] # This is CRC[8:16] | |
| new_after_ppct = crc_suffix + bytes(new_with_markers) | |
| # Rebuild the full file | |
| # ZTE header (unchanged) + XOR(PPCT header + data after PPCT) | |
| return reference_file[:128] + xor_data(bytes(orig_ppct) + new_after_ppct, XOR_KEY) | |
| # ============================================================================= | |
| # ROUTER COMMUNICATION | |
| # ============================================================================= | |
| class ZTERouter: | |
| """ | |
| Class for interacting with ZTE ZXIC router web interface. | |
| Handles authentication, session management, config backup and restore. | |
| Uses standard library urllib (no external dependencies). | |
| """ | |
| def __init__(self, host: str = "192.168.100.1", username: str = "admin", | |
| password: str = "admin"): | |
| """ | |
| Initialize router connection. | |
| Args: | |
| host: Router IP address | |
| username: Web interface username | |
| password: Web interface password | |
| """ | |
| self.host = host | |
| self.base_url = f"http://{host}" | |
| self.username = username | |
| self.password = password | |
| # Set up cookie handling for session management | |
| self.cookie_jar = http.cookiejar.CookieJar() | |
| self.opener = urllib.request.build_opener( | |
| urllib.request.HTTPCookieProcessor(self.cookie_jar) | |
| ) | |
| self.opener.addheaders = [ | |
| ('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36') | |
| ] | |
| def _request(self, url: str, data: dict = None, timeout: int = 10) -> bytes: | |
| """ | |
| Make HTTP request with session cookies. | |
| Args: | |
| url: URL to request | |
| data: POST data (if None, makes GET request) | |
| timeout: Request timeout in seconds | |
| Returns: | |
| Response body as bytes | |
| """ | |
| if data: | |
| encoded_data = urllib.parse.urlencode(data).encode('utf-8') | |
| req = urllib.request.Request(url, data=encoded_data) | |
| else: | |
| req = urllib.request.Request(url) | |
| with self.opener.open(req, timeout=timeout) as response: | |
| return response.read() | |
| def _get_login_token(self) -> Optional[str]: | |
| """ | |
| Get the login token from the router's login page. | |
| The router uses a dynamic token (Frm_Logintoken) for authentication. | |
| Returns: | |
| Login token string or None if not found | |
| """ | |
| try: | |
| html = self._request(f"{self.base_url}/").decode('utf-8', errors='replace') | |
| # Extract Frm_Logintoken from the page | |
| match = re.search(r'Frm_Logintoken"\s*value="([^"]+)"', html) | |
| if match: | |
| return match.group(1) | |
| # Try alternative pattern | |
| match = re.search(r'getObj\("Frm_Logintoken"\)\.value\s*=\s*"([^"]+)"', html) | |
| if match: | |
| return match.group(1) | |
| except Exception as e: | |
| print(f"Warning: Could not get login token: {e}") | |
| return None | |
| def login(self) -> bool: | |
| """ | |
| Authenticate with the router. | |
| Returns: | |
| True if login successful, False otherwise | |
| """ | |
| token = self._get_login_token() | |
| login_data = { | |
| 'Username': self.username, | |
| 'Password': self.password, | |
| 'action': 'login', | |
| } | |
| if token: | |
| login_data['Frm_Logintoken'] = token | |
| try: | |
| resp = self._request(f"{self.base_url}/", login_data) | |
| html = resp.decode('utf-8', errors='replace') | |
| # Check if login was successful | |
| if 'logout' in html.lower() or 'Frm_Logintoken' not in html: | |
| return True | |
| # Try to access a protected page | |
| test_resp = self._request( | |
| f"{self.base_url}/getpage.gch?pid=1002&nextpage=manager_dev_conf_t.gch" | |
| ) | |
| test_html = test_resp.decode('utf-8', errors='replace') | |
| if 'backup' in test_html.lower() or 'restore' in test_html.lower(): | |
| return True | |
| except Exception as e: | |
| print(f"Login error: {e}") | |
| return False | |
| def _get_session_token(self, html: str) -> Optional[str]: | |
| """ | |
| Extract session token from page HTML. | |
| The router uses _SESSION_TOKEN for form submissions. | |
| Args: | |
| html: Page HTML content | |
| Returns: | |
| Session token string or None | |
| """ | |
| match = re.search(r'var session_token\s*=\s*"([^"]+)"', html) | |
| if match: | |
| return match.group(1) | |
| return None | |
| def download_backup(self, max_retries: int = 3) -> bytes: | |
| """ | |
| Download configuration backup from router via web interface. | |
| The backup is triggered by submitting a form to: | |
| getpage.gch?pid=101&nextpage=manager_dev_config_t.gch | |
| Args: | |
| max_retries: Maximum number of download attempts | |
| Returns: | |
| Raw encrypted backup file contents | |
| Raises: | |
| Exception: If download fails | |
| """ | |
| for attempt in range(max_retries): | |
| try: | |
| # Get session token from backup page | |
| page_html = self._request( | |
| f"{self.base_url}/getpage.gch?pid=1002&nextpage=manager_dev_config_t.gch" | |
| ).decode('utf-8', errors='replace') | |
| session_token = self._get_session_token(page_html) | |
| if not session_token: | |
| raise Exception("Could not extract session token") | |
| # Get cookies as string for raw socket request | |
| cookies = '; '.join([f'{c.name}={c.value}' for c in self.cookie_jar]) | |
| # Use raw socket for more control over connection handling | |
| sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | |
| sock.settimeout(180) # 3 minute timeout | |
| sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 8388608) | |
| sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) | |
| sock.connect((self.host, 80)) | |
| # Build multipart form | |
| boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW' | |
| body = f'--{boundary}\r\nContent-Disposition: form-data; name="config"\r\n\r\n\r\n' | |
| body += f'--{boundary}\r\nContent-Disposition: form-data; name="_SESSION_TOKEN"\r\n\r\n{session_token}\r\n' | |
| body += f'--{boundary}--\r\n' | |
| body_bytes = body.encode() | |
| # Send HTTP request with cookies and keep-alive | |
| request = f"POST /getpage.gch?pid=101&nextpage=manager_dev_config_t.gch HTTP/1.1\r\n" | |
| request += f"Host: {self.host}\r\n" | |
| request += f"Content-Type: multipart/form-data; boundary={boundary}\r\n" | |
| request += f"Content-Length: {len(body_bytes)}\r\n" | |
| if cookies: | |
| request += f"Cookie: {cookies}\r\n" | |
| request += "Connection: keep-alive\r\n\r\n" | |
| sock.sendall(request.encode() + body_bytes) | |
| # Read response headers byte by byte | |
| response = b'' | |
| while not response.endswith(b'\r\n\r\n'): | |
| chunk = sock.recv(1) | |
| if not chunk: | |
| raise Exception("Connection closed during headers") | |
| response += chunk | |
| # Parse Content-Length | |
| expected_size = 0 | |
| for line in response.decode().split('\r\n'): | |
| if line.lower().startswith('content-length:'): | |
| expected_size = int(line.split(':')[1].strip()) | |
| break | |
| if expected_size == 0: | |
| raise Exception("No Content-Length in response") | |
| # Read body with progress | |
| content = b'' | |
| bytes_remaining = expected_size | |
| while bytes_remaining > 0: | |
| ready = select.select([sock], [], [], 60) # 60 second timeout | |
| if ready[0]: | |
| chunk = sock.recv(min(65536, bytes_remaining)) | |
| if not chunk: | |
| raise Exception(f"Connection closed with {bytes_remaining} bytes remaining") | |
| content += chunk | |
| bytes_remaining -= len(chunk) | |
| else: | |
| raise Exception(f"Timeout waiting for data, {bytes_remaining} bytes remaining") | |
| sock.close() | |
| # Verify we got all data | |
| if len(content) != expected_size: | |
| raise Exception(f"Size mismatch: got {len(content)}, expected {expected_size}") | |
| # Verify header | |
| if content[:16] != ZTE_HEADER_MAGIC: | |
| raise Exception("Downloaded file does not have valid ZTE header") | |
| return content | |
| except Exception as e: | |
| if attempt == max_retries - 1: | |
| raise | |
| print(f"Attempt {attempt + 1} failed: {e}, retrying...") | |
| time_module.sleep(1) | |
| raise Exception("Failed to download backup after all retries") | |
| def upload_restore(self, data: bytes) -> bool: | |
| """ | |
| Upload and restore configuration to router. | |
| WARNING: This will restart the router! | |
| Args: | |
| data: Encrypted config file to restore | |
| Returns: | |
| True if upload successful | |
| Raises: | |
| Exception: If upload fails | |
| """ | |
| # Navigate to restore page first | |
| self._request(f"{self.base_url}/getpage.gch?pid=1002&nextpage=manager_dev_conf_t.gch") | |
| # Get fresh token | |
| token = self._get_login_token() | |
| # Build multipart form data manually | |
| boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW' | |
| body = BytesIO() | |
| # Add config file | |
| body.write(f'--{boundary}\r\n'.encode()) | |
| body.write(b'Content-Disposition: form-data; name="config_file"; filename="config.bin"\r\n') | |
| body.write(b'Content-Type: application/octet-stream\r\n\r\n') | |
| body.write(data) | |
| body.write(b'\r\n') | |
| # Add action field | |
| body.write(f'--{boundary}\r\n'.encode()) | |
| body.write(b'Content-Disposition: form-data; name="action"\r\n\r\n') | |
| body.write(b'restore\r\n') | |
| # Add token if available | |
| if token: | |
| body.write(f'--{boundary}\r\n'.encode()) | |
| body.write(b'Content-Disposition: form-data; name="Frm_Logintoken"\r\n\r\n') | |
| body.write(token.encode()) | |
| body.write(b'\r\n') | |
| body.write(f'--{boundary}--\r\n'.encode()) | |
| # Make request | |
| restore_url = f"{self.base_url}/manager_dev_config_t.gch" | |
| req = urllib.request.Request( | |
| restore_url, | |
| data=body.getvalue(), | |
| headers={ | |
| 'Content-Type': f'multipart/form-data; boundary={boundary}' | |
| } | |
| ) | |
| try: | |
| with self.opener.open(req, timeout=60) as response: | |
| if response.status == 200: | |
| return True | |
| except urllib.error.HTTPError as e: | |
| raise Exception(f"Restore failed with status {e.code}") | |
| return False | |
| # ============================================================================= | |
| # CLI COMMANDS | |
| # ============================================================================= | |
| def cmd_backup(args): | |
| """Execute backup command.""" | |
| print(f"Connecting to router at {args.host}...") | |
| router = ZTERouter(args.host, args.username, args.password) | |
| if not router.login(): | |
| print("Error: Failed to login to router") | |
| print("Check your credentials and try again") | |
| return 1 | |
| print("Login successful!") | |
| print("Downloading configuration backup...") | |
| try: | |
| backup_data = router.download_backup() | |
| print(f"Downloaded {len(backup_data)} bytes") | |
| if args.raw: | |
| # Save raw encrypted file | |
| with open(args.output, 'wb') as f: | |
| f.write(backup_data) | |
| print(f"Raw backup saved to: {args.output}") | |
| else: | |
| # Decrypt and save XML | |
| print("Decrypting configuration...") | |
| xml_data = decrypt_web_backup(backup_data) | |
| with open(args.output, 'wb') as f: | |
| f.write(xml_data) | |
| print(f"Decrypted config saved to: {args.output}") | |
| print(f"XML size: {len(xml_data)} bytes") | |
| return 0 | |
| except Exception as e: | |
| print(f"Error: {e}") | |
| return 1 | |
| def cmd_decrypt(args): | |
| """Execute decrypt command.""" | |
| print(f"Reading encrypted file: {args.input}") | |
| with open(args.input, 'rb') as f: | |
| data = f.read() | |
| print(f"File size: {len(data)} bytes") | |
| try: | |
| # Detect file type based on header | |
| if data[:16] == ZTE_HEADER_MAGIC: | |
| print("Detected: Web backup file (ZTE header)") | |
| xml_data = decrypt_web_backup(data) | |
| elif data[:4] == INTERNAL_MAGIC: | |
| print("Detected: Internal config file (FTP)") | |
| xml_data = decrypt_internal_config(data) | |
| else: | |
| print("Warning: Unknown file format, trying web backup decryption...") | |
| xml_data = decrypt_web_backup(data) | |
| with open(args.output, 'wb') as f: | |
| f.write(xml_data) | |
| print(f"Decrypted config saved to: {args.output}") | |
| print(f"XML size: {len(xml_data)} bytes") | |
| return 0 | |
| except Exception as e: | |
| print(f"Decryption error: {e}") | |
| return 1 | |
| def cmd_encrypt(args): | |
| """Execute encrypt command.""" | |
| print(f"Reading XML file: {args.input}") | |
| with open(args.input, 'rb') as f: | |
| xml_data = f.read() | |
| print(f"XML size: {len(xml_data)} bytes") | |
| print("Encrypting for restore...") | |
| try: | |
| encrypted = encrypt_for_restore(xml_data) | |
| with open(args.output, 'wb') as f: | |
| f.write(encrypted) | |
| print(f"Encrypted backup saved to: {args.output}") | |
| print(f"File size: {len(encrypted)} bytes") | |
| return 0 | |
| except Exception as e: | |
| print(f"Encryption error: {e}") | |
| return 1 | |
| def cmd_restore(args): | |
| """Execute restore command.""" | |
| print("=" * 60) | |
| print("WARNING: This will restore configuration and RESTART the router!") | |
| print("=" * 60) | |
| if not args.force: | |
| response = input("Are you sure you want to continue? [y/N]: ") | |
| if response.lower() != 'y': | |
| print("Restore cancelled") | |
| return 0 | |
| print(f"Reading config file: {args.input}") | |
| with open(args.input, 'rb') as f: | |
| data = f.read() | |
| # Check if it's XML (needs encryption) or already encrypted | |
| if data[:16] == ZTE_HEADER_MAGIC: | |
| print("File is already encrypted (ZTE backup format)") | |
| encrypted_data = data | |
| elif data.strip().startswith(b'<'): | |
| print("File is XML, encrypting for upload...") | |
| encrypted_data = encrypt_for_restore(data) | |
| else: | |
| print("Error: Unknown file format") | |
| return 1 | |
| print(f"Connecting to router at {args.host}...") | |
| router = ZTERouter(args.host, args.username, args.password) | |
| if not router.login(): | |
| print("Error: Failed to login to router") | |
| return 1 | |
| print("Login successful!") | |
| print("Uploading configuration...") | |
| try: | |
| if router.upload_restore(encrypted_data): | |
| print("Configuration uploaded successfully!") | |
| print("Router is restarting...") | |
| return 0 | |
| except Exception as e: | |
| print(f"Restore error: {e}") | |
| return 1 | |
| def cmd_info(args): | |
| """Display information about an encrypted config file.""" | |
| print(f"Reading file: {args.input}") | |
| with open(args.input, 'rb') as f: | |
| data = f.read() | |
| print(f"\nFile size: {len(data)} bytes") | |
| print(f"First 32 bytes: {data[:32].hex()}") | |
| if data[:16] == ZTE_HEADER_MAGIC: | |
| print("\nFile type: ZTE Web Backup") | |
| print(f"ZTE Header: {data[:16].hex()}") | |
| encry_type = struct.unpack('<I', data[0x50:0x54])[0] | |
| print(f"Encryption type: {encry_type}") | |
| # Peek at XOR decrypted header | |
| xor_dec = xor_data(data[128:256], XOR_KEY) | |
| if xor_dec[12:16] == PPCT_MAGIC: | |
| print(f"PPCT Magic: {xor_dec[12:16]} (valid)") | |
| elif data[:4] == INTERNAL_MAGIC: | |
| print("\nFile type: Internal Config (FTP)") | |
| sizes = struct.unpack('>II', data[10:18]) | |
| print(f"Sizes in header: {sizes}") | |
| else: | |
| print("\nFile type: Unknown") | |
| # ============================================================================= | |
| # MAIN | |
| # ============================================================================= | |
| def main(): | |
| parser = argparse.ArgumentParser( | |
| description='ZTE ZXIC Router Configuration Backup/Restore Tool', | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| epilog=""" | |
| Examples: | |
| %(prog)s backup -o config.xml Backup and decrypt config | |
| %(prog)s backup --raw -o config.bin Backup raw encrypted file | |
| %(prog)s decrypt -i config.bin -o config.xml Decrypt a backup file | |
| %(prog)s encrypt -i config.xml -o config.bin Encrypt XML for restore | |
| %(prog)s restore -i config.xml Restore config (restarts router!) | |
| %(prog)s info -i config.bin Show file info | |
| """ | |
| ) | |
| # Global options | |
| parser.add_argument('-H', '--host', default='192.168.100.1', | |
| help='Router IP address (default: 192.168.100.1)') | |
| parser.add_argument('-u', '--username', default='admin', | |
| help='Web interface username (default: admin)') | |
| parser.add_argument('-p', '--password', default='admin', | |
| help='Web interface password (default: admin)') | |
| subparsers = parser.add_subparsers(dest='command', help='Available commands') | |
| # Backup command | |
| backup_parser = subparsers.add_parser('backup', help='Download and decrypt config') | |
| backup_parser.add_argument('-o', '--output', required=True, | |
| help='Output file path') | |
| backup_parser.add_argument('--raw', action='store_true', | |
| help='Save raw encrypted file (no decryption)') | |
| # Decrypt command | |
| decrypt_parser = subparsers.add_parser('decrypt', help='Decrypt a backup file') | |
| decrypt_parser.add_argument('-i', '--input', required=True, | |
| help='Input encrypted file') | |
| decrypt_parser.add_argument('-o', '--output', required=True, | |
| help='Output XML file') | |
| # Encrypt command | |
| encrypt_parser = subparsers.add_parser('encrypt', help='Encrypt XML for restore') | |
| encrypt_parser.add_argument('-i', '--input', required=True, | |
| help='Input XML file') | |
| encrypt_parser.add_argument('-o', '--output', required=True, | |
| help='Output encrypted file') | |
| # Restore command | |
| restore_parser = subparsers.add_parser('restore', | |
| help='Restore config (RESTARTS ROUTER!)') | |
| restore_parser.add_argument('-i', '--input', required=True, | |
| help='Config file to restore (XML or encrypted)') | |
| restore_parser.add_argument('-f', '--force', action='store_true', | |
| help='Skip confirmation prompt') | |
| # Info command | |
| info_parser = subparsers.add_parser('info', help='Show file information') | |
| info_parser.add_argument('-i', '--input', required=True, | |
| help='Config file to analyze') | |
| args = parser.parse_args() | |
| if not args.command: | |
| parser.print_help() | |
| return 1 | |
| commands = { | |
| 'backup': cmd_backup, | |
| 'decrypt': cmd_decrypt, | |
| 'encrypt': cmd_encrypt, | |
| 'restore': cmd_restore, | |
| 'info': cmd_info, | |
| } | |
| return commands[args.command](args) | |
| if __name__ == '__main__': | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment