commit 117622257b70e61d6b65e29ac9db6964910ff01c Author: Victor Golovanenko Date: Sun Jul 27 21:04:50 2025 +0300 Initial commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..7c21deb --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# C-Data ONU tools +Tools for managing settings of the C-Data FD511G-X xPON ONU (and possibly other models), which are not available from the web interface: +enabling/disabling shell access, getting SSH/Telnet password. + +![C-Data FD511G-X ONU](img/C-Data%20FD511G-X%20front%20side.webp) + +## Abstract +The ONU web interface (usually available at http://192.168.101.1) allows you to download a configuration backup as a file. This is a regular XML file that is encrypted using XOR encryption with a key of `0x26`. +The tools presented here are designed to load, read, and modify this configuration file. + +## Tools +- `enable_shell_access.py` helps to enable/disable SSH/Telnet root shell access from LAN/WAN and change username & password. + Usage: + ``` + $ python enable_shell_access.py --help + usage: enable_shell_access.py [-h] [-a AUTHORIZATION] [-d] [-i {LAN,WAN}] [-p {SSH,Telnet}] [--change-username CHANGE_USERNAME] [--change-password CHANGE_PASSWORD] [host] + + Program to enable or disable Telnet or SSH access to the C-Data FD511G-X ONU + + positional arguments: + host ONU host (IP address or domain). Default: 192.168.101.1. + + options: + -h, --help show this help message and exit + -a, --authorization AUTHORIZATION + Web interface authorization credentials, separated by colon. Default: adminisp:adminisp. + -d, --disable Disable access. If this argument is not passed, it enables access. + -i, --interface {LAN,WAN} + Interface for enabling access: LAN or WAN. Default: LAN. + -p, --protocol {SSH,Telnet} + Protocol for enabling access: SSH or Telnet. Default: SSH. + --change-username CHANGE_USERNAME + Set new username + --change-password CHANGE_PASSWORD + Set new password + ``` + Usage examples: + - enable LAN SSH access: + ``` + $ python enable_shell_access.py 192.168.101.1 + Logging in to http://192.168.101.1 with adminisp:adminisp... + Current SSH credentials: + username: manu + password: c8a9b413 + + SSH access from LAN enabled + Uploading new configuration to the ONU... + Config successfully uploaded. Do you want to reboot ONU now? (Y/n) : Y + ONU reboot error. The device is probably already rebooting. + ``` + - disable LAN Telnet access: + ``` + $ python enable_shell_access.py 192.168.101.1 -p Telnet -d + Logging in to http://192.168.101.1 with adminisp:adminisp... + Current Telnet credentials: + username: manu + password: c8a9b413 + + Telnet access from LAN disabled + Uploading new configuration to the ONU... + Config successfully uploaded. Do you want to reboot ONU now? (Y/n) : Y + Device is rebooting. Please wait. + ``` + - login with custom web interface credentials: + ``` + $ python enable_shell_access.py 192.168.101.1 -a adminisp:admin1234 + Logging in to http://192.168.101.1 with adminisp:admin1234... + Current SSH credentials: + username: manu + password: c8a9b413 + + SSH access from LAN enabled + Uploading new configuration to the ONU... + Config successfully uploaded. Do you want to reboot ONU now? (Y/n) : Y + ONU reboot error. The device is probably already rebooting. + ``` + +- `extract_credentials.py` extracts credentials from the binary config file, including SSH and Telnet username & password, and also saves decrypted version of the config file. + Usage: + ``` + $ python extract_credentials.py ~/config.bin + File decrypted and saved to "/home/user/config.xml" + + Web interface credentials: + root-level account: + username: adminisp + password: adminisp + + user-level account: + username: admin + password: admin + + + Shell credentials (Telnet and SSH): + username: manu + password: c8a9b413 + ``` diff --git a/enable_shell_access.py b/enable_shell_access.py new file mode 100644 index 0000000..5be149b --- /dev/null +++ b/enable_shell_access.py @@ -0,0 +1,173 @@ +import argparse +import hashlib +import io +import sys +import xml.etree.ElementTree as ET +from typing import Literal, Optional, Union + +import httpx + + +CONFIG_ENC_KEY = 0x26 + + +def xor_xcrypt(data: bytes, key: int, result_as_string: bool = False) -> Union[bytes, str]: + if result_as_string: + return ''.join(chr(c ^ key) for c in data) + else: + return bytes(c ^ key for c in data) + + +def get_shell_access_creds(config: ET.Element, protocol: Literal['SSH', 'Telnet'] = 'SSH') -> str: + remote_management = config.find(f'.//RemoteManagement/{protocol.upper()}') + username = remote_management.find('TelnetUserName' if protocol == 'Telnet' else 'UserName').text + password = remote_management.find('TelnetPassword' if protocol == 'Telnet' else 'Password').text + return (f'username: {username}\n' + f'password: {password}\n') + + +def set_shell_access_creds(config: ET.Element, protocol: Literal['SSH', 'Telnet'] = 'SSH', + username: Optional[str] = None, password: Optional[str] = None) -> bool: + remote_management = config.find(f'.//RemoteManagement/{protocol.upper()}') + if remote_management is None: + return False + if username: + remote_management.find('TelnetUserName' if protocol == 'Telnet' else 'UserName').text = username + if password: + remote_management.find('TelnetPassword' if protocol == 'Telnet' else 'Password').text = password + return True + + +def is_access_enabled(config: ET.Element, + interface: Literal['LAN', 'WAN'] = 'LAN', protocol: Literal['SSH', 'Telnet'] = 'SSH') -> bool: + ssh_policy = config.find(f".//ServiceControl/*[ServiceList='{protocol.upper()}'][Interface='{interface}']/Policy") + return ssh_policy.text == "Permit" + + +def enable_access(config: ET.Element, interface: Literal['LAN', 'WAN'] = 'LAN', + protocol: Literal['SSH', 'Telnet'] = 'SSH', disable: bool = False) -> bool: + service_policy = config.find( + f".//ServiceControl/*[ServiceList='{protocol.upper()}'][Interface='{interface}']/Policy") + remote_management = config.find(f'.//RemoteManagement/{protocol.upper()}/{protocol}Enable') + if (service_policy is not None) and (remote_management is not None): + service_policy.text = "Discard" if disable else "Permit" + if not disable: + remote_management.text = '1' + return True + return False + + +def create_arg_parser(): + arg_parser = argparse.ArgumentParser( + description='Program to enable or disable Telnet or SSH access to the C-Data FD511G-X ONU') + arg_parser.add_argument( + 'host', + nargs='?', + default='192.168.101.1', + help='ONU host (IP address or domain). Default: %(default)s.') + arg_parser.add_argument( + '-a', '--authorization', + default='adminisp:adminisp', + help='Web interface authorization credentials, separated by colon. Default: %(default)s.') + arg_parser.add_argument( + '-d', '--disable', + action='store_true', + help='Disable access. If this argument is not passed, it enables access.') + arg_parser.add_argument( + '-i', '--interface', + choices=('LAN', 'WAN'), + default='LAN', + type=str, + help='Interface for enabling access: LAN or WAN. Default: %(default)s.') + arg_parser.add_argument( + '-p', '--protocol', + choices=('SSH', 'Telnet'), + default='SSH', + type=str, + help='Protocol for enabling access: SSH or Telnet. Default: %(default)s.') + arg_parser.add_argument( + '--change-username', + type=str, + help='Set new username') + arg_parser.add_argument( + '--change-password', + type=str, + help='Set new password') + return arg_parser + + +if __name__ == '__main__': + arguments = create_arg_parser().parse_args() + host = arguments.host + username, password = arguments.authorization.split(':', maxsplit=1) + disable = arguments.disable + interface = arguments.interface + protocol = arguments.protocol + change_username = arguments.change_username + change_password = arguments.change_password + + http = httpx.Client(base_url=f'http://{host}') + hashed_password = hashlib.md5(password.encode('utf-8')).hexdigest() + print(f'Logging in to http://{host} with {username}:{password}...', file=sys.stderr) + resp = http.post( + '/post.json', + json={'module': 'login', 'username': username, 'encryPassword': hashed_password} + ) + if resp.json()['code'] != 0: + sys.exit(f'Authorization error:\n{resp.text}') + + resp = http.post('/boaform/formSaveConfig', data={'save_cs': 'backup_config'}) + resp.raise_for_status() + encrypted_config = resp.content + decrypted_config = xor_xcrypt(encrypted_config, CONFIG_ENC_KEY, result_as_string=True).replace(',', '\n') + + config = ET.fromstring(decrypted_config) + config_changed = False + + print(f'Current {protocol} credentials:\n', get_shell_access_creds(config, protocol), sep='') + if change_username or change_password: + if set_shell_access_creds(config, protocol, change_username, change_password): + print(f'{protocol} changed to:\n', get_shell_access_creds(config, protocol), sep='') + config_changed = True + else: + print(f'Failed to change {protocol} credentials', file=sys.stderr) + + enabled = is_access_enabled(config, interface, protocol) + if disable == enabled: + if enable_access(config, interface, protocol, disable=disable): + print(f'{protocol} access from {interface}', 'disabled' if disable else 'enabled') + config_changed = True + else: + print(f'Failed to {"disable" if disable else "enable"} {protocol}. Check configuration', file=sys.stderr) + else: + _tip = [f'{protocol} access from {interface} is already {"enabled" if enabled else "disabled"}.'] + if enabled: + _tip.append(f"To disable, run \"{' '.join(sys.argv)} --disable\"") + print(' '.join(_tip)) + + if config_changed: + modified_config = ET.tostring(config, encoding='utf-8', xml_declaration=True, short_empty_elements=False) + encrypted_modified_config = xor_xcrypt(modified_config.replace(b'\n', b','), CONFIG_ENC_KEY) + + print('Uploading new configuration to the ONU...', file=sys.stderr) + resp = http.post('/boaform/formSaveConfig', files={ + 'binary': ('config.bin', io.BytesIO(encrypted_modified_config), 'application/octet-stream')}) + resp.raise_for_status() + if resp.json()['code'] != 0: + sys.exit(f'Error uploading config:\n{resp.text}') + resp = http.get('/get.json', params={'module': 'restore_info'}) + resp.raise_for_status() + if resp.json()['code'] != 0: + sys.exit(f'Error applying config:\n{resp.text}') + + if input('Config successfully uploaded. Do you want to reboot ONU now? (Y/n) : ').lower() != 'n': + try: + resp = http.post('/post.json', json={'module': 'dev_config', 'reboot': 1}) + except (httpx.ConnectError, httpx.ReadError): + print('ONU reboot error. The device is probably already rebooting.') + sys.exit(0) + else: + resp.raise_for_status() + print('Device is rebooting. Please wait.', file=sys.stderr) + else: + print('Configuration has not been changed.') diff --git a/extract_credentials.py b/extract_credentials.py new file mode 100644 index 0000000..6e5088f --- /dev/null +++ b/extract_credentials.py @@ -0,0 +1,60 @@ +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +key = 0x26 + + +def extract_web_interface_creds(tree: ET) -> str: + x_gc_login = tree.find('.//X_GC_LOGIIN') + result = [] + for account in x_gc_login: + level = account.find('UserLevel').text + username = account.find('Username').text + password = account.find('Passwd').text + result.append(f'{level}-level account:\nusername: {username}\npassword: {password}') + return '\n\n'.join(result) + + +def extract_shell_access_creds(tree: ET) -> str: + telnet = tree.find('.//RemoteManagement/TELNET') + ssh = tree.find('.//RemoteManagement/SSH') + telnet_username = telnet.find('TelnetUserName').text + telnet_password = telnet.find('TelnetPassword').text + ssh_username = ssh.find('UserName').text + ssh_password = ssh.find('Password').text + if (telnet_username == ssh_username) and (telnet_password == ssh_password): + return f'Shell credentials (Telnet and SSH):\nusername: {ssh_username}\npassword: {ssh_password}' + else: + return f'''Shell credentials: +Telnet: +username: {telnet_username} +password: {telnet_password} + +SSH: +username: {ssh_username} +password: {ssh_password}''' + + +try: + config_filename = Path(sys.argv[1]) +except IndexError: + print(f'Usage: {sys.argv[0]} ', file=sys.stderr) + sys.exit(1) + +with open(config_filename, 'rb') as infile: + test_chunk = infile.read(7) + infile.seek(0) + if test_chunk.startswith(b'