From APK to first frame: inside Mibo's protocol
How we followed Mibo Smart from login to media, separated evidence from hypotheses, and reproduced an independent connection through its first frame.

A video screen looks like one operation: tap a camera and wait for the picture. Behind that tap are at least four different conversations. The account talks to an HTTPS service; the client discovers a device and negotiates a route to it; a transport with sequence numbers and ACKs runs over UDP; finally, encapsulated media is checked, decrypted, and handed to decoders.
The motivation came from home. The girlfriend of our founder, Jojo, asked us for a native desktop app to keep an eye on the cats we care for. There are more than 20 of them now. We want to open their cameras in an experience designed for Linux, macOS, and Windows; that request became Noctua.
Before taking the protocol apart, we mapped the official options: local access through documented protocols, a desktop client, and an enterprise streaming API. Each has different conditions. Later we compare them with the remote route we actually tested, without treating historical support answers as a description of today’s offering.
This is the first entry in a series about the research behind Noctua, our future camera client. Our starting question was concrete: could an identity of our own reach a first frame without running Mibo Smart or its proprietary SDK? For the authorized iM3B-C camera in our lab, the observed answer was yes. That does not imply general compatibility, a production player, or a vendor security vulnerability.
The path to that answer was less straight than the final diagram suggests. A symbol list can look like a specification; an open socket can look like a video stream; a PLAY 200 can look like a finished image. None of those equivalences is safe. We looked for the next boundary in each case and required real bytes to cross it.
What counts as a finding?#
We fixed the baseline to the installed Mibo Smart 3.1.1 APK, Android 16 on the AVD, and one authorized camera we own. We recorded APK, DEX, and ARM64-library hashes before attaching meaning to native offsets. Research stayed within the permitted environment and account. Names, addresses, credentials, and private packets are absent from this article.
We used four levels of evidence:
- Installed code: JADX, DEX, and disassembly reveal a possible path, not proof that it ran.
- Synthetic parity: invented input produces the same output in an installed routine and a standard implementation. This establishes byte rules, not server acceptance.
- Official execution: metadata hooks, player states, and network captures tie an operation to a real app session.
- Independent execution: a process without Mibo or its SDK builds fresh requests and sockets, receives peer responses, and decodes media using standard primitives.
We name each level for what it actually proves. The name ProxySession in a binary does not prove a remote proxy; onPlayBegan does not prove that a pixel reached the display; an HTTP 200 does not prove that the next packet is video. The evidence references beside our conclusions point to entries in the research report.1
Finding a path through the application#
The app combines account services, device models, an HTTPS client, and native networking and media libraries. Startup prepares OEM configuration, service routing, terminal identity, and playback subsystems. A static call graph suggested GetToken → Login → inventory → camera detail → player. But asynchronous phase execution meant a sorted initializer list could not be treated as a guaranteed runtime timeline.
On the media side, we traced how a camera object becomes native inputs. Product and device IDs, camera account and password, P2P token, and playback parameters serve distinct roles. One important finding was simply not calling all of them “the token.” The account token signs HTTPS reads; the camera credential participates in signaling and the media key; the Info salt is a different, fresh negotiation value. Crossing those boundaries incorrectly produced requests that looked plausible yet were rejected by the service.
Instrumentation had its own obstacles. Some Frida and Android 16 combinations crashed the process; the app’s local sounds and hardware decoder also disrupted observation. We isolated each fault, used temporary workarounds only in the authorized process, and repeated measurements with smaller hooks. An observer crash was never recorded as a protocol rejection. To verify bytes, we reconstructed traffic and used independent implementations of hashes, AES, and codecs. The installed version and hashes remained the reference for offsets and call graphs.
The native path: from writev to the media socket#
Our first attempt to observe sending watched send, sendto, and sendmsg. It missed the expected datagram. That was an observer coverage gap: in the installed ARM64 ELF, the active path reached writev. Return addresses led to FUN_009cca98 (RVA 0x8cca98), which assembles the PTCP header and vectors; FUN_009cb710 passes the vectors to the socket wrapper; FUN_009a40d8 handles the result; and FUN_009a5f34 calls writev. The first iovec is 24 bytes, but later vectors may extend the datagram. Recording only that vector would have produced a false “24-byte packet”.2
In the other direction, we identified recv → FUN_009a43bc → FUN_009cbd10 → FUN_009ccee8 → FUN_009ccf34. The observed drain loop uses a 2,048-byte buffer; the next guard rejects input above 65,535 bytes; the parser requires at least 24 bytes and locates extensions before releasing the body. These are limits of the examined implementation, not a promised MTU or protocol safety guarantee. Hooks at different wrappers could count one read twice, so we cross-checked native calls against datagrams and byte counters rather than turning hook counts into packet counts.3
This map is explanatory pseudocode we wrote from the identified boundaries. It is not SDK source, and it does not specify transitions we have not exercised:
received UDP
→ FUN_009ccf34: PTCP header and actual body position
→ FUN_009ce778: window, sequence, contiguous release
→ FUN_009c2250: exact proxy-message length and channel
→ FUN_009d65bc: bytes after the proxy header to a local socket
→ HTTP transport CSock: PLAY, SDP, interleaved records
Static analysis connects these functions; the official session connects the channel to the loopback socket and player states; byte-for-byte comparison of rebuilt PTCP and local TCP connects both halves. None of these proofs, by itself, identifies the UDP peer as the physical camera.4
In the ARM64 ELF identified in Appendix III, the map’s FUN_* names are Ghidra addresses with image base 0x100000; RVA = Ghidra address − 0x100000. The table maps each function to its RVA. CSock names the HTTP component in the explanatory flow, not one function with a single RVA.
| Symbol / point in the installed ELF | Relevant operation | What it resolved — and its limit |
|---|---|---|
FUN_009cca98 · 0x8cca98 | Assembles the PTCP header in the first, 24-byte iovec | Later vectors also form the datagram; the first vector’s length was not the packet length. |
0x8cb710 → 0x8a40d8 → 0x8a5f34 | Hands vectors to the wrapper, handles the result, and calls writev | Explains the silent send* hooks; a local send result still does not prove peer acceptance. |
FUN_009ccf34 · 0x8ccf34 | Reads extension control at byte 12 and finds the body after extensions | Prevents classifying byte 24 as a proxy tag in every datagram. |
FUN_009ce778 · 0x8ce778 | Admits PTCP data by window and releases contiguous bytes | Rollover and partial overlap still lack complete independent parity. |
FUN_009c2250 · 0x8c2250 | Validates and dispatches proxy messages by channel | Branch presence does not prove every message type ran. |
FUN_009d65bc · 0x8d65bc | Partially delivers data bytes to the local socket | Advancing a local offset does not prove remote peer acceptance. |
0x8c59e8 | Compares w7 with 3 while x8 carries an aggregate return | Corrects the decompiler’s visual argument numbering; it does not prove which branch the session chose. |
This is our own table of operations verified in the identified binary, not a transcription of proprietary disassembly. Each row separates a possible code path from an observed session.
A methodological detour appeared in transport factory FUN_009c59a4: the decompiler showed an extra first parameter. The ARM64 instruction at RVA 0x8c59e8 compares w7 with 3; an aggregate return uses hidden register x8, which Ghidra displayed as a pseudo-parameter and thereby shifted its visual argument numbering. Correcting that ABI prevented us from assigning TCP/PTCP selection to the wrong argument. The comparison and constructors exist in the binary, but static analysis does not prove which branch this particular camera opening chose; a warm reopening also did not call the factory during the observed window.5
Account, terminal, and inventory are different steps#
Before requesting video, the client needs a terminal identity the account trusts. The app asks user.account.GetTwoStepVerifyState for the verification state. When trust is required, a separate code and GrantingCredit flow follows; it does not happen merely because a client knows the password. In our research, a separate terminal identity gained trust through the legitimate flow with a user-entered code. Two attempts returned vendor code 11001. Comparing their fields and transport with the native request led to an accepted third attempt. Because we changed a group of public client attributes together, we did not isolate the one field responsible for 11001. A state readback showed credit=true for that identity.6
Next, GetToken exchanges the login credential for a username, token, session ID, and service route. Login uses the returned context; an authenticated configuration read confirmed the session. An older session returned code 12002, while a new GetToken/Login restored reads. That contrast establishes the observed recovery path, not a universal expiry time, automatic refresh rule, or revocation policy.
SaaS calls have a JSON data body and a signature over precisely defined bytes. In this layer, Content-MD5 is Base64 of MD5 over the uncompressed JSON body; it does not specify the encoding of the identically named header in NFPOST signaling messages. The canonical text ends every line with LF and contains method, URI, digest, content type, and ordered x-pcs-* headers. In the authenticated phase, the key is the UTF-8 encoding of the returned token’s lowercase hexadecimal MD5 text, rather than the raw 16-byte digest. Revisions and serialization details differ by operation, so the code below constructs and signs a synthetic example; it is not a ready client:
import base64
import hashlib
import hmac
body = b'{"data":{"example":"synthetic"}}'
digest = base64.b64encode(hashlib.md5(body).digest()).decode("ascii")
headers = [
("x-pcs-apiver", "191204"),
("x-pcs-client-ua", "synthetic-only"),
("x-pcs-date", "2000-01-01T00:00:00Z"),
("x-pcs-nonce", "synthetic-nonce-only"),
("x-pcs-username", r"account\synthetic@example.invalid"),
]
canonical = "\n".join([
"POST", "/pcs/v1/example.synthetic", digest,
"application/json; charset=utf-8",
*(f"{name}:{value}" for name, value in headers), "",
])
key = hashlib.md5(b"synthetic-token").hexdigest().encode("ascii")
signature = base64.b64encode(
hmac.new(key, canonical.encode("utf-8"), hashlib.sha256).digest()
).decode("ascii")
expected_canonical = (
"POST\n/pcs/v1/example.synthetic\n"
"rEtqwrqwnR90eVh0VWvw4g==\n"
"application/json; charset=utf-8\n"
"x-pcs-apiver:191204\n"
"x-pcs-client-ua:synthetic-only\n"
"x-pcs-date:2000-01-01T00:00:00Z\n"
"x-pcs-nonce:synthetic-nonce-only\n"
"x-pcs-username:account\\synthetic@example.invalid\n"
)
assert digest == "rEtqwrqwnR90eVh0VWvw4g=="
assert canonical == expected_canonical
assert signature == "teHxHnfCQYo4tnY0z2d/8B6I58Sxh7S3KcmtITEIjBE="
assert hmac.new(key, canonical.replace("\n", "\r\n").encode(),
hashlib.sha256).digest() != base64.b64decode(signature)
The invented token and route cannot produce a valid request. This teaching vector fixes exact bytes and expected output; replacing LF with CRLF is its negative case. Six separate synthetic vectors were compared with the installed routine. We do not attribute that unmeasured parity to this teaching example. Live acceptance came from separate calls made by the research terminal.
With an authenticated session, UserFamilyGet selects family context; BasicList paginates devices; BasicInfoGet supplies the details needed for the authorized camera. An independent run traversed three inventory pages (2 + 2 + 1), selected the permitted camera, and obtained configuration and wrapped credentials. Status and some capability reads also succeeded, but a button in the app or a local ability field is not proof of server-side write permission. We did not perform persistent camera changes for this article.
Negotiation does not start with RTSP#
The observed path is neither a direct RTSP URL nor ONVIF discovery. The client sends P2P messages shaped like NFPOST, queries a discovery route, and receives encrypted Info. After decrypting Info with provisioning parameters identified in the APK, it finds a randsalt for a new P2P request. That second request uses fresh dynamic fields, WSSE authentication, and a separate camera-credential role. Responses supply address candidates and an Identify value that we related to the connectivity exchange.
The challenge was avoiding a fixed rule for a candidate list. Across official sessions, the media candidate changed position. In one capture, two ports at the same public address stayed active concurrently: one carried control traffic, the other the media channel. That is not evidence that “the first route failed, then the second was tried.” It also does not identify whether the public peer is the camera behind NAT or a vendor intermediary. The research client selected a route that responded to its connectivity transaction, not a list index.
On a Mac, with no Mibo, AVD, or proprietary libraries running, we repeated the chain with an already trusted identity of our own: current configuration and details over HTTPS, unsalted P2P, discovery and Info, salted P2P, candidate connectivity, PTCP, proxy-channel opening, PLAY 200, a complete DHAV record, and close. In another run with our own identity and static parameters provisioned offline, the process also went from fresh login to a decoded frame while Mibo was stopped.7 The evidence covers those routes and that camera model, not every NAT topology, firmware, or account state.
Sanitized receipt: one crossing on the AVD#
The following sequence belongs to one independent AUTH-070 run in a separate Android app_process, with Mibo stopped and no SDK class loaded. Response sizes are from that run; the three-page inventory mentioned earlier was a different inventory experiment. Camera names, addresses, session values, and media bodies are absent.
| Step | Observed messages and result | Gate crossed |
|---|---|---|
| Account | GetToken and Login: HTTP 200/vendor code 10000; 228- and 1,363-byte responses | New account context accepted; no prior token reused. |
| Authorized data | GlobalConfigGet, UserFamilyGet, BasicList, and BasicInfoGet: HTTP 200/vendor code 10000; 9,422-, 375-, 2,760-, and 19,120-byte responses | Family and authorized camera selected from current responses; this run had five devices in one BasicList response. |
| Signaling | Initial P2P NFPOST accepted; one discovery and one Info response; Info decrypted with standard primitives; second NFPOST with fresh randsalt accepted | Candidates and Identify came from this negotiation, not a Mibo cache. |
| Candidate and transport | Matching connectivity exchange, matching PTCP control, and proxy-channel open | The channel received matching CONN before PLAY. |
| Media and close | PLAY returned 200; a complete 91,919-byte DHAV record passed checksum and trailer checks; standard KDF, verifier, and AES-OFB yielded one decoded 640 × 480 H.264 frame; close received a response and bilateral DISC followed | One frame was decoded without rendering or saving media. The close exchange does not prove immediate remote resource reclamation. |
“Accepted” and “matching” refer to this bounded run and its state checks; the table publishes no material for authenticating to someone else’s account. The later Mac run and official-app presentation are separate experiments identified in the final receipt. In the separate SIGNAL-114 Mac run, both P2P exchanges received 100 then 200; discovery and Info received 200; the independent 1,327-byte PLAY received 200, followed by a 96,467-byte DHAV record whose envelope was validated. That Mac record was not decrypted or decoded in this run; host decoding came later.
PTCP: turning datagrams into an ordered conversation#
Observed UDP packets begin with PTCP. That name does not fix one wire layout. Two public projects in the Dahua/Easy4IP family, dh-p2p and dahua-p2p, describe another 24-byte header containing PID, LMID, and RMID. In this Mibo session’s ELF and captures, the same offsets participate in extension control, flags, window, and clocks. WebRTC PseudoTCP is a structural reference for sequence, ACK, and clocks, not proof of interoperability or code provenance.
| Offset | Mibo Smart 3.1.1 in this research | Cited Dahua P2P projects | Basis for the Mibo reading |
|---|---|---|---|
0–3 | PTCP | PTCP | Capture and native producer. |
4–7 / 8–11 | Byte sequence / cumulative ACK, big-endian | Bytes sent / received | Observed byte progress; ACK and buffer traced in native code. |
12 / 13 / 14–15 | Optional extension control / flags / window | First four PID bytes | Native producer and parser; 28 packets had extensions in one bounded capture. |
16–19 / 20–23 | Sender clock / peer echo | LMID / RMID | Producer and clock correlation; rollover and full RTT remain partial. |
From 24 | Body or extension length followed by body | Type, 24-bit length, realm, padding | Native parser and capture reconstruction; wire equivalence is not assumed. |
In this study, the body may begin at byte 24 or after an extension region. Treating byte 24 as a proxy tag in every packet would mistake extension length for message type. The table compares two readings of different implementations; it does not establish that a client for one can speak to the other.
Within the body, proxy-protocol tags distinguish data (0x10), open (0x11), markers such as CONN/DISC (0x12), and periodic control (0x13). Channel SYN and CONN preceded PLAY. The transport counter advances in body bytes, not datagram counts. Duplicates, reordering, ACKs, and window state cannot be handled as a naive series of recvfrom calls.
The extractor below demonstrates length admission and the extension boundary only. The small reassembly function works only on an offline fixture without rollover or permanent gaps. It does not implement full PTCP reliability:
def ptcp_body(packet: bytes) -> tuple[int, bytes]:
if len(packet) < 24 or packet[:4] != b"PTCP":
raise ValueError("invalid PTCP header")
offset = 24
if packet[12]:
if packet[12] != 1 or len(packet) < 25:
raise ValueError("unsupported extension control")
offset = 25 + packet[24]
if offset > len(packet):
raise ValueError("truncated extensions")
position = 25
while position < offset:
if position + 2 > offset:
raise ValueError("truncated extension header")
tag, size = packet[position:position + 2]
if tag != 0 or size < 8 or size % 4 or position + 2 + size > offset:
raise ValueError("invalid range extension")
position += 2 + size
return int.from_bytes(packet[4:8], "big"), packet[offset:]
def contiguous(chunks: list[tuple[int, bytes]], start: int) -> bytes:
parts: dict[int, bytes] = {}
for sequence, body in chunks:
if sequence in parts and parts[sequence] != body:
raise ValueError("conflicting duplicate")
parts[sequence] = body
result = bytearray()
while start in parts:
body = parts.pop(start)
result.extend(body)
start += len(body)
return bytes(result)
assert contiguous([(3, b"def"), (0, b"abc"), (0, b"abc")], 0) == b"abcdef"
Two invented datagrams, one contiguous sequence#
Concrete bytes make the boundary easier to see. We constructed the two datagrams below for this article: channel 0x01020304, opposite-direction ACK 0, window 0x1000, clock 1, and ASCII payloads abc/def. No field came from the camera, account, or a PCAP. In each datagram, the first 24 bytes are PTCP and the next 15 are a 12-byte proxy 0x10 message plus three data bytes:
PTCP 0–23 proxy 24–38
A 50 54 43 50 00 00 10 00 00 00 00 00 00 00 10 00
00 00 00 01 00 00 00 00 |10 00 00 03 01 02 03 04
00 00 00 00 61 62 63 abc
B 50 54 43 50 00 00 10 0f 00 00 00 00 00 00 10 00
00 00 00 01 00 00 00 00 |10 00 00 03 01 02 03 04
00 00 00 00 64 65 66 def
50 54 43 50 spells PTCP. Bytes 4–7 are the big-endian sequence: 0x1000 in A and 0x100f in B. The 0x0f difference is 15 PTCP body bytes, not three final payload bytes. In the body, 10 marks data; 00 03 at body+2 declares three payload bytes; 01 02 03 04 selects the same channel in both messages. The four bytes at body+8 are zero in this example, without assigning them a general meaning. The native validator requires an exact total length of 12 + 3 for this kind.8 The bytes 10 00 00 03 also permit the public reading “type plus a 24-bit length of 3.” Therefore, A and B teach reordering; they do not distinguish the two layouts.
A separate synthetic check derived from the installed parser’s extension rule does distinguish them: starting from an invented data packet, set byte 12 = 01; at byte 24, 0a declares ten extension bytes, followed by 00 08 and eight range bytes. The 0x10 body now starts at offset 35. The synthetic check-ptcp-framing.py verifier accepts this shift and rejects truncated extensions. In one bounded capture, 28 packets had extension control 1 and length 0x0a at byte 24.9 In the cited public PID/LMID/RMID layout, byte 12 belongs to the PID and byte 24 starts the body. These invented bytes make the shift visible; PTCP… stands for the 24-byte header, with 01 at offset 12:
offset 0–23: PTCP… (byte 12 = 01)
offset 24: 0a extension length
offset 25–34: 00 08 00 00 00 00 00 00 00 00
offset 35: 10 00 00 03 … proxy body begins
This synthetic check comes from local analysis; it is not a real packet or proof of parity between the public client and Mibo.
If B arrives before A, a receiver expecting 0x1000 can queue B but cannot release def as the start of the stream. Once A arrives, 30 PTCP bytes are contiguous and the two channel messages can release abc followed by def. An identical copy of A adds nothing. An illustrative cumulative ACK would remain at 0x1000 until A and advance to 0x101e after A+B. This illustrates native admission and contiguous release; it does not claim these invented ACKs were observed on the wire or settle rollover, partial overlaps, retransmission, or congestion.10
The decisive test was not “we saw UDP.” We grouped the data channel, discarded equal duplicates, ordered fragments by sequence, and compared the rebuilt stream against the loopback TCP bytes the SDK passed to its player. Two complete streams — 2,994,000 and 779,936 bytes — were byte-identical to their local reconstructions, including 1,263 and 326 DHAV records. This binds the PTCP channel to consumed media without assuming the physical identity of the UDP peer.11
A second envelope follows PTCP ordering. In native producer FUN_009c4ca4, channel-open kind 0x11 takes 20 bytes: an LE32 write of 0x11 at 0–3, BE32 channel at 4–7, six zero bytes, BE16 port at 14–15, and candidate IPv4 at 16–19. This is a static map of the producer write: 11 00 00 00 can also be described on the wire as type 0x11 followed by three zero bytes. The LE32 write alone does not settle those four bytes’ wire semantics or prove that any channel was accepted. Acceptance came at another boundary: in the observed session, the matching channel received a 0x12 marker with literal bytes CONN, changed state, and only then reached PLAY 200. The same dispatcher statically recognizes SINV and DISC; those names must not be turned into a universal state enum.12
This illustrative C transcription shows only the layout inferred from the native producer. channel, port, and candidate_ipv4 stand for invented reader-supplied values; the put_* helpers express byte order and are not copied SDK functions. The fragment opens no socket and grants no authorization:
uint8_t open[20] = {0};
put_le32(open + 0, 0x11); // channel-open tag
put_be32(open + 4, channel); // synthetic channel
put_be16(open + 14, port); // candidate port
copy_four(open + 16, candidate_ipv4);
For 0x10 data, the dispatcher requires exactly 12 + BE16(body+2) bytes and uses the BE32 channel at body+4 to find the stream. FUN_009d65bc sends only bytes after that 12-byte header to the local socket. If the socket accepts only part, the object retains the remaining offset before processing another block. This explains why the PTCP-to-loopback comparison can be byte-identical after proxy headers are removed, and why a new client must keep datagram, PTCP sequence, proxy message, and media-byte boundaries distinct.13
Loopback is an internal app bridge: a proxy-session object writes one TCP end and the HTTP media transport reads the other. Socket hooks, aliases, and byte counters confirmed that handoff. A new client can feed its parser directly with the ordered bytes; copying the loopback architecture is not a protocol requirement.
PLAY 200 is not yet a frame#
Inside the proxy channel, media opening uses an HTTP-shaped PLAY request. 0, 2, and 3 are parameter values of the examined operation, not names of standard RTSP methods: on the observed route, 0 opens, 2 serves as application keepalive, and 3 requests close. This keepalive is distinct from PTCP ACKs and proxy periodic control. PLAY received HTTP/1.1 200 and SDP; the same conversation then carried records prefixed by $, a track byte, and a big-endian length. Each record contains a DHAV body. The prefix observed on this route is six bytes: $, one track byte, and a BE32 length. That differs from the four bytes ($, channel, BE16) defined for interleaved RTP/RTCP in RFC 2326 §10.12. In the analyzed capture, BE32 matched the DHAV length of a 93,140-byte record, beyond BE16’s range; that check comes from the real stream, not the invented fixture.14 A response to operation value 3 can share a proxy packet with media, so the parser must be incremental.
The response does not end when records begin#
The entirely synthetic fixture contains 145 bytes. We built it to force parser state changes after PTCP/proxy reassembly: an HTTP response with five private bytes, an interleaved record, and another HTTP response. These offsets belong to the ordered stream, not UDP datagrams:
| Offset | Bytes | Interpretation in the fixture |
|---|---|---|
0–51 | 52 | HTTP/1.1 200 OK, Cseq: 1, Private-Length: 5, blank line, and v=0\r\n |
52–57 | 6 | 24 00 00 00 00 28: $, track 0, big-endian length 40 |
58–97 | 40 | Invented DHAV: initial 44 48 41 56, type 0xfc, trailer 64 68 61 76 28 00 00 00 |
98–144 | 47 | Another HTTP/1.1 200 OK, Cseq: 2, Private-Length: 0 |
The parser must wait for all six bytes of the $ prefix before it knows the record length, wait for the declared 40 bytes before validating DHAV, then resume recognizing HTTP. A network split may end a header, end a trailer, or begin another response at the very next byte. The fixture passed all 146 two-chunk splits and uniform chunk sizes; malformed lengths, checksum, and trailer were rejected. This exercises incremental framing with invented data, not PTCP reliability or video decoding.15 The real capture showed that a method-3 response and media can share one proxy message, which makes this alternation practical rather than decorative.16
An observed DHAV record has DHAV at its start, a little-endian total length at offset 12, an extension beginning at offset 24, an additive checksum at byte 23, and a dhav trailer followed by the repeated total length. Byte 22 contains the extension length. The following example checks only that envelope in a synthetic record capped at 1 MiB; it decides nothing about codec, encryption, or authorization:
def validate_dhav(record: bytes) -> bytes:
if len(record) < 32 or len(record) > 1_048_576:
raise ValueError("record size")
total = int.from_bytes(record[12:16], "little")
payload_start = 24 + record[22]
if (record[:4] != b"DHAV" or total != len(record)
or payload_start > len(record) - 8
or record[23] != sum(record[:23]) % 256
or record[-8:-4] != b"dhav"
or int.from_bytes(record[-4:], "little") != total):
raise ValueError("invalid DHAV envelope")
return record[payload_start:-8]
These checks do not authenticate the sender. A one-byte checksum detects accidental corruption; it is not a MAC. A real parser also needs per-field bounds, traversal of known extension tags, incremental buffering, and explicit handling of unknown variants. Our synthetic HTTP/DHAV fixture exercised every two-piece split and uniform chunk size, plus rejection of invalid headers, trailers, and lengths.17
From protected payload to pixel#
In the records we examined, 0xfd carried protected H.264 keyframes, 0xfc carried subsequent clear video, and 0xf0 carried protected AAC. Other types, including 0xf1 in some sessions, do not yet have a demonstrated universal role. The FFmpeg DHAV demuxer recognizes 0xf1 while probing and skips that record while reading; that does not identify the role of the 0xf1 bodies seen here. A 44-byte 0xb5 extension identified, in the tested variant, a clear prefix, protected-region length, derivation fields, verifier, and IV. The media key came from camera credentials legitimately obtained in device detail and derived with standard primitives; it was not copied from a native cache.
We applied AES-256-OFB only to the declared region, preserving the remainder of the record. Dahua’s security white paper v2 describes AES-256-OFB frame protection and a digital-envelope key exchange in its product offering. That is technology-family context, not proof that the documented envelope participates in the 0xb5 derivation measured in this variant. The observed verifier combines SHA-256 and CRC-16 over the documented fields. It helped establish that key and envelope matched this record, but does not replace cryptographic authentication. The resulting frame contained Annex B SPS/PPS/IDR. FFmpeg decoded a 640 × 480 H.264 Main picture. In a bounded independent capture, it also decoded 63 video units; in another, 168 mono 16 kHz AAC-LC units produced standard PCM. A Mac-host run reached a protected record and close, and a later verification decoded media. None of this required displaying or publishing the private image.
The official app supplied an important comparison: in the same instrumented opening, we observed channel establishment, PLAY 200, native media setup, decrypted keyframe metadata, onPlayBegan, and the first successful EGL swap. States and timing correlate that swap with the opening; we did not identify its pixels or prove that the displayed frame matched the one decoded on Mac. The media evidence therefore goes beyond PLAY 200 or an SPS header. We also did not equate offline decoding of a frame with a stable desktop renderer, A/V synchronization, or automatic recovery.
How official options change the picture#
The path above describes the remote opening we measured in Mibo Smart, not every way to access an Intelbras camera. The Mibo Smart manual documents ONVIF Profile S and Intelbras-1 for DVR/NVR, after app enrollment and with the recorder on the same network; it notes an exception for the iM8. This makes standards-based local access a real alternative to investigate, but does not establish that this camera, across networks, offers the same sessions, permissions, and features as the P2P path studied here.
The official context has changed over time. In 2018, Intelbras support said the Mibo line focused on mobile monitoring; in 2024, it said the line had no API or SDK. Those historical answers alone do not describe today’s offering: the GDI manual now documents an HTTP API for Mibo accounts, including live-stream creation for iM cameras with cloud viewing. It requires company registration, account linking, and a quota-based streaming plan. We have not tested this API, its media format, latency, or availability for this camera. It may provide another integration path but neither validates nor invalidates the observed PTCP bytes. SIM Play is an official Windows and macOS client, with compatibility conditional on the product or Intelbras-1; support distinguishes its local access from Mibo cloud access. We do not assume it uses the same account or offers the same features as Mibo Smart.
Flashing replacement firmware onto the camera was a no-go for Noctua’s scope. Requiring people to modify their cameras before using a desktop client would make setup too difficult and add risk to a device they already depend on. We therefore investigated paths that work with the camera’s existing firmware.
Noctua aims to become a native Linux, macOS, and Windows client. This experiment crossed authentication and media boundaries on a Mac host, but has not proved a finished application, feature parity with official offerings, or portability across the three platforms. Comparing GDI, local ONVIF, and the P2P route on remote access, cost, session control, privacy, and model coverage belongs to later product work, not to this first-frame finding.
What this first crossing actually gives us#
The most useful result for Noctua is a verifiable chain of inputs and outputs: our own trusted identity, fresh login, paged inventory, camera detail, signaling with a fresh Info salt, responsive candidate, PTCP and proxy, PLAY, protected DHAV, independently derived key, decoded H.264 frame, and requested close. Official-app and independent-process experiments intersect at byte, state, and return-code boundaries. That is enough to start an SDK without copying the application or proprietary SDK at runtime.
To expose the evidence trail and its limits, this receipt separates the environments and experiments that crossed the most important boundaries:
| Run / milestone18 | Boundary crossed | What it does not establish |
|---|---|---|
| Mibo on AVD — transport | Rebuilt PTCP byte-identical to player-consumed loopback in two captures | Physical identity of the UDP peer; these precede the official opening in the next row |
| Mibo on AVD — presentation | CONN, PLAY 200, decrypted keyframe metadata, onPlayBegan, and first EGL swap correlated in one opening | Displayed pixel identity; this is a different run from the independent frame |
| Own process on AVD — bootstrap | Trusted terminal, fresh login and current data, signaling, decoded frame, and requested close with Mibo stopped | Desktop client or trust lifetime |
| Own process on Mac — media | Fresh sockets and PLAY 200 with protected DHAV; in a later session, 119 video units and 128 AAC units decoded after close | Sustained rendering, audible audio, or A/V synchronization |
| Offline fixture — parser | Arbitrary HTTP/DHAV synthetic splits and malformed-envelope rejection | All variants or server acceptance |
The research archive retains sanitized fixtures and a private corpus encrypted with SOPS. We will not publish raw captures, proprietary binaries, or camera frames. The snippets above let readers test framing ideas on invented data. Reproducing server acceptance requires an account and device of one’s own, consent, and comparable network conditions.
It is still a bounded sample. We need sustained operation with clocks and A/V presentation, more topologies and device variants, complete behavior under loss and reconnection, session expiry and renewal, and proof of remote resource release after close. Accepted configuration reads do not make write commands ready. A later entry in this series can address these boundaries and, separately, vulnerability research on proprietary protocols with its own confirmation criteria. We make no claim of an exploitable vulnerability here.
If there is one methodological lesson, it is this: reverse engineering advances when a hypothesis meets a byte, a state, and an independent experiment capable of disproving it. The diagram is the end of the story; the tests that survive our assumptions are the real story.
Appendix I — LLMs in the protocol lab#
This investigation had a second instrument alongside the disassembler, proxy, and hooks: an LLM working inside a research harness. It helped us move quickly across Java code, ARM64 pseudocode, execution records, and fixtures. The speedup came from shortening the interval between question, hypothesis, experiment, and correction. We did not time a team without an LLM, so we cannot assign a numerical acceleration factor; “faster” describes the work cycle we observed, not a benchmark.
The harness starts from an immutable anchor: hashes of the APK and libraries, architecture, and environment version. It feeds the LLM small excerpts with provenance and runs checks that cannot accept an explanation merely because it sounds plausible. For hypotheses that became findings, we recorded a condition that could disprove them. The result returns to an evidence index with separate statuses: installed code, synthetic parity, official execution, independent acceptance, or an open gap. A new binary version requires revalidating the offsets and conclusions that depend on it.
excerpt with hash and origin → hypothesis + falsifiable test
→ synthetic fixture / installed routine
→ bounded observation / independent client
→ evidence, counterexample, or unknown
One concrete example was the silence from send, sendto, and sendmsg where we expected a PTCP datagram. The harness kept that result as an observer-coverage failure, followed the native call chain, and found writev. Reading only one iovec would have suggested a 24-byte packet; checking every vector against the datagram prevented that conclusion. Elsewhere, the transport factory’s pseudocode shifted arguments because of an aggregate return hidden in x8. The instruction using w7 was a better arbiter than the decompiled signature. An LLM can speed up navigation and propose checks in both cases; bytes and ABI decided the outcome.
The same pattern worked for pieces reproducible without a camera. For HTTPS signing, the harness generated artificial inputs and compared canonical bytes with the installed routine and standard primitives. For PTCP and HTTP/DHAV, invented fixtures split the stream at arbitrary positions and introduced invalid lengths, checksums, and trailers. That exposed fragile interpretations before any live session. Synthetic parity establishes a local rule; only an accepted request, a state transition, or a frame decoded by an independent process crosses the next boundary.
There is an operational limit, too. APKs, logs, and tool responses are untrusted data to an LLM: they may contain hostile instructions, secrets, or values from another session. The harness restricted tool scope, suppressed sensitive values, used synthetic fixtures in public checks, and kept the private corpus encrypted. In the experiments described here, model suggestions did not replace authorization; published examples use only synthetic data, and the media corpus remained encrypted. The LLM was useful precisely because an experiment could give it a verifiable “no.” Without that mechanism, it could only produce a convincing specification of a protocol that might not exist.
Appendix II — the reverse-engineering workbench#
No single tool handed us “the protocol.” We built a workbench where each instrument answers a different question and leaves an artifact another instrument can challenge. The versions below identify this research environment, not universal requirements for reproducing the method.
| Instrument | What we asked | Evidence produced — and its limit |
|---|---|---|
| ADB, AAPT, and SHA-256 | Which APK was actually installed on the AVD? | Package path, manifest, version, and APK/DEX/ELF hashes fixed the baseline. Identifying a binary does not reveal which branch ran. |
| JADX 1.5.6 and DEX | Who builds requests and passes data to the player? | Call graphs, DTOs, serialization, and field provenance. When the decompiler failed on a retry switch, we returned to DEX and fallback mode; plausible pseudocode was not treated as truth. |
| Ghidra 12.1.4 and NDK 29 LLVM tools | How does ARM64 assemble buffers and call native routines? | Xrefs, layouts, registers, and disassembly checked offsets and ABI. We mapped image base to ELF RVA; a function’s existence does not prove this session used it. |
| Frida 17.23.3 inside Mibo | Did the predicted path run during the authorized opening? | Short, binary-guarded hooks recorded states, sizes, times, and codes without exporting secret values. A missing hook event or observer crash is not a protocol failure. |
tcpdump on the AVD and Python parsers | Which bytes crossed UDP, PTCP, proxy, and local TCP? | Private PCAP and duplicate-aware reconstruction let us compare two external streams with loopback byte for byte. Stream equality does not cryptographically identify the UDP peer. |
| Python/Java 21, standard primitives, and FFmpeg | Does our interpretation reproduce bytes and decode media? | Synthetic vectors compared signing and derivation with installed routines; parsers rejected malformed envelopes; FFmpeg decoded H.264/AAC. Isolated decoding does not prove continuous presentation or A/V synchronization. |
| Sanitized fixtures and SOPS | How can we repeat checks without publishing the camera? | Artificial cases, counts, and hashes remained reviewable; authorized real media bodies stayed encrypted. A synthetic fixture tests a local hypothesis, not acceptance by an arbitrary account, server, or firmware. |
Switching instruments corrected mistakes that would have been hard to see on one screen. Our first send probe saw no datagrams because it watched send/sendto/sendmsg; disassembly led to writev and the capture confirmed the complete vector size. In another case, JADX emitted an exception for part of the retry predicate; fallback mode recovered the control flow, and 108 artificial inputs were compared against the installed predicate without triggering camera reconnection. Those checks answer local questions. Connection acceptance and the first frame required the independent session described in the main article.19
Sensitive material stayed outside the publishable record: the extracted APK, decompilation, PCAP, and raw logs remained in the private lab; hooks emitted only allowlisted metadata. The approved media corpus was stored with SOPS, and this page’s examples use invented bytes. Readers can audit the shape of the test without turning someone’s image, credential, or network topology into a public demonstration.
Appendix III — hashes of the analyzed baseline#
The SHA-256 values below identify the installed Mibo Smart 3.1.1 (100315), arm64-v8a baseline labeled APK-001. The APKs establish the ELF’s origin; the three listed .so files received analysis recorded in the research report. libCommonSDK.so underpins this article’s native transport and media map. libLCSign.so and libEasy4IpComponent.so were inventoried and imported into Ghidra; their presence or static analysis does not show that they ran during the observed opening. The hashes come from extraction manifests, not a new capture for this revision. We do not redistribute the binaries.
| Artifact | Bytes | SHA-256 |
|---|---|---|
base.apk | 160,791,927 | 67b4ca547b0c6f6bf64f4e24cd100563e7c13e0c6fc31e40c8127677fd17509c |
split_config.arm64_v8a.apk | 127,786,028 | 1ab512856731c243e2e0a5349082587eed2e23c67072502c54509552507eb384 |
lib/arm64-v8a/libCommonSDK.so | 17,376,392 | 6a01a2a6aa23acd6c616f671948ac4c11e50f4976968d3250c7172feecd9a5f4 |
lib/arm64-v8a/libLCSign.so | 354,104 | 46591595b8c0a9ef9b34565dd87b60afdf50ef10842ccefa8d206345d536ad67 |
lib/arm64-v8a/libEasy4IpComponent.so | 6,655,152 | 5760d473b944a259e2b7191aa544a75eaec3806bdc0c63c4d74f60db5f1bb61e |
To check a legitimately obtained copy of the same version and architecture, compute SHA-256 over the complete APK or the bytes of the lib/arm64-v8a/<name> entry extracted from the split, without repackaging or modifying it. A different hash calls for revalidating RVAs, call graphs, and binary-dependent conclusions; by itself it does not prove a behavior change.
The
AUTH,TRANSPORT,SIGNAL, andMEDIAcodes identify internal research-report entries, not public URLs. We retain them to distinguish static mapping, official-app observation, and acceptance by the independent client. ↩︎Internal reference:
TRANSPORT-006— thewritevpath and firstiovec. ↩︎Internal reference:
TRANSPORT-008/009— receive path and hook-counting limits. ↩︎Internal reference:
TRANSPORT-001/015–017— PTCP, proxy-channel, and loopback linkage. ↩︎Internal reference:
TRANSPORT-018–020— transport-factory ABI and observation limit. ↩︎Internal reference:
AUTH-068— trust of our own terminal identity. ↩︎Internal reference:
AUTH-070— login and decoded frame without Mibo running. ↩︎Internal reference:
TRANSPORT-016— proxy-message length validation. ↩︎Internal reference:
TRANSPORT-009— observed extension and parser; syntheticcheck-ptcp-framing.pyfixture. The latter is not installed-SDK parity. ↩︎Internal reference:
TRANSPORT-010/011— PTCP admission and contiguous release. ↩︎Internal reference:
TRANSPORT-001— byte-for-byte parity of the rebuilt stream. ↩︎Internal reference:
TRANSPORT-013/017, SIGNAL-114— proxy-channel opening and confirmation. ↩︎Internal reference:
TRANSPORT-016/017— proxy-data validation and partial delivery. ↩︎Internal reference:
SIGNAL-070— six-byte prefix and DHAV length checked in the capture. ↩︎Internal reference:
MEDIA-039— synthetic HTTP/DHAV fixture. ↩︎Internal reference:
SIGNAL-073/080— close response interleaved with media. ↩︎Internal reference:
MEDIA-039— rejection of malformed DHAV envelopes. ↩︎Internal IDs for the five rows, in order:
TRANSPORT-001;MEDIA-065;AUTH-068/070;SIGNAL-114,MEDIA-069;MEDIA-039. These are separate runs, not one PCAP or one frame. ↩︎Internal references:
APK-001(baseline);NATIVE-002/003/004(Ghidra, LLVM, and offsets);TRANSPORT-001/006(reconstruction andwritev);RECOVERY-004/005(fallback and predicate parity);MEDIA-064(encrypted corpus). ↩︎
Continue to part 2: Inside Mibo, part 2: P2P tunnels, native decoders, and the birth of Noctua →