Skip to content

Instantly share code, notes, and snippets.

@ankurpandeyvns
Created November 25, 2025 13:47
Show Gist options
  • Select an option

  • Save ankurpandeyvns/3c90a7012b17be0e66121653e63bd93f to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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