← writing

sep 10, 2026 · 13 min read

Rooting My Projector After We Forgot the Remote

A missing remote led to a persistent Go server on an Android projector, an OEM backdoor, and a phone-controlled player backed by NetMirror and an anime aggregator API.

We carried a projector to a friend's place for movie night and nobody brought the remote. My friend Veeraj Matnale suggested looking up its IR codes so we could use a phone with an IR blaster. We couldn't find a matching set for this OEM model anywhere.

It ran Android TV, so we plugged it into a laptop and started sending input keyevent commands over Android Debug Bridge (ADB). Up, down, home, OK, one command at a time. We could open apps and get around the menus, but by then people had to leave. We never watched the movie.

I couldn't quite leave it there. If the laptop could control it, I wanted to make the same thing work from my phone. A web remote, with the server running on the projector itself, so I wouldn't need the laptop every time.

I'd used Linux and done some IoT tinkering, but I hadn't worked much with Android's boot chain or partition layout. A lot of this was me figuring out what this particular box was doing. The offsets and input permissions below are specific to it, so don't copy them onto another projector.

The projector already had root

This was what I found when I checked the device properties:

ro.product.model=t950s_be311
ro.build.version.release=13
ro.build.version.sdk=33
ro.build.fingerprint=Google/ap201/ap201:13/TQ2A.230405.003.E1/15:userdebug/test-keys
ro.boot.flash.locked=0
ro.boot.vbmeta.device_state=unlocked
ro.boot.verifiedbootstate=orange
ro.boot.veritymode=disabled
ro.boot.slot_suffix=_a
ro.boot.dynamic_partitions_retrofit=true

It was a userdebug build signed with test-keys. The bootloader was unlocked and verified-boot enforcement was disabled. adb shell id came back with uid=0(root) gid=0(root), and the daemon was running as adbd --root_seclabel=u:r:su:s0.

So I already had root. The manufacturer had shipped it that way. The problem was keeping access: ADB over TCP disappeared after a reboot.

I've redacted credentials, remote server addresses, and identifiers from the backdoor investigation below.

Why ADB kept turning itself off

There was a script at /system/bin/check_adb, started from /vendor/etc/init/hw/init.amlogic.rc when Android finished booting:

service checkadb /system/bin/check_adb
    class main
    user root
    group root
    disabled
    oneshot
    seclabel u:r:recovery:s0
 
on property:sys.boot_completed=1
    start checkadb

I checked the init directories under /vendor, /system, /odm, /product, and /system_ext. There was one copy of the script and one place that started it. Reading it explained why ADB kept disappearing:

#!/system/bin/sh
 
log_file=/data/log.txt
 
if [ ! -d /product/priv-app/product_priv-app_PrebuiltGmsCorePano ]; then
  echo no dir > $log_file
  exit
fi
 
stop adbd
settings put global adb_enabled 0
settings put global wifi_adb_enabled 0
setprop service.adb.tcp.port 3333
 
sleep 25
 
for dir in /mnt/media_rw/*; do
  if [ -d "$dir" ]; then
    if [ -f "$dir/adben.txt" ]; then
      setprop service.adb.tcp.port 5555
      settings put global adb_enabled 1
      settings put global wifi_adb_enabled 1
      start adbd
    fi
  fi
done

On every boot, it stopped ADB, disabled the settings, waited 25 seconds, and looked for adben.txt on a USB drive. If it found that file, it enabled ADB again on port 5555.

When I first connected, ADB was enabled on port 3333. That didn't match what the script would leave behind, and I couldn't tell who or what had changed it. But the script would disable it again on the next boot.

I wanted to make it enable ADB every time. The awkward part was that the script lived on system_a.

Figuring out where the file lived

The storage was a single 15.4GB eMMC device, /dev/block/mmcblk0. I worked through the properties, /proc/partitions, the by-name links, and lpdump to map it out:

  • The device reported retrofit A/B dynamic partitions with slot _a active. Logical system partitions lived inside a 2,621,440,000-byte super partition at mmcblk0p29.
  • boot_a/b, init_boot_a/b, vendor_boot_a/b, vbmeta_a/b, and vbmeta_system_a/b were physical partitions. There was no dedicated recovery partition.
  • metadata was 64MB, userdata was about 12GB with roughly 10GB free, and misc was 2MB. Both metadata and userdata used f2fs.
  • Inside super, the active logical partitions appeared as /dev/block/mapper/*_a, backed by dm-0 through dm-7. These included system, vendor, product, odm, system_ext, and the kernel-module partitions.

According to lpdump, system_a started at sector 2048 inside super, or byte offset 1,048,576. Slot _b had no extents. Despite the A/B names, there wasn't a second populated system slot to fall back to.

avbtool info_image --image vbmeta_a.img reported Flags: 1, or AVB_VBMETA_IMAGE_FLAGS_HASHTREE_DISABLED. The mounts had no dm-verity layer. Hash descriptors still existed for boot images, while the system-partition hashtree descriptors weren't being enforced.

That meant changing the system file wouldn't trip a hashtree check at boot on this device. Writing to the wrong place could still leave me with a projector that wouldn't boot.

The backdoor I found along the way

While looking through the boot triggers, I found a privileged OEM app with RECEIVE_BOOT_COMPLETED and RECOVERY permissions. I unpacked its two APKs and followed the dex strings from a BootReceiver to an extractBootupScript routine.

It wrote shell scripts to a world-writable vendor partition and ran them as root on every boot. One of them had this loop:

#!/system/bin/sh
server=[REDACTED-IP]
user=[REDACTED-CREDENTIAL]
password=[REDACTED-CREDENTIAL]
chip_name=$(getprop ro.chip.name)
serial=$(getprop ro.serialno)
product=$(getprop persist.jixin.product)
head="$chip_name"_"$product"_"$serial"
f_shell="$head".sh
...
while true; do
  sleep 30;
  ret=$(logcat -d | grep "java.lang.Object java.util.Map.get")
  if [ ! -z "$ret" ]; then
    pm clear com.google.android.youtube.tv
    file_name=yt2-"$chip_name"_"$product"_"$serial"_$1.sh
    touch /data/$file_name; echo fdsjkl > /data/$file_name
    ftp_upload $file_name
  fi
  logcat -c;
done

The downloader had plaintext FTP credentials for two remote addresses and used pm install -r on APKs it downloaded. The filenames included the device serial number. The logs showed a matching script had been fetched and run during that boot, then deleted.

So a factory app could download remote code and run it as root on my projector. I don't know what the manufacturer intended it for. The loop also watched logcat for a particular error string, cleared YouTube's data when it appeared, uploaded a marker file, and cleared the logs.

I disabled the package and killed the script that was already running:

adb shell pm disable-user <redacted-package-name>
adb shell kill -9 <pid-of-already-running-run.sh>

I left the scripts on disk. Disabling the package was reversible and stopped the boot path I'd traced. I hadn't audited the whole firmware, but I wanted this particular mechanism off before leaving the projector running on my home network.

Getting a write onto a read-only partition

check_adb was 652 bytes, mode 0755, owned by root:shell, and stored in one extent on system_a. Android mounted that filesystem as / through /dev/block/dm-0.

mount -o remount,rw / returned success, but blockdev --getro /dev/block/dm-0 still returned 1. Trying blockdev --setrw didn't persist. The device-mapper configuration kept the logical partition read-only independently of the disabled verity checks.

The dmctl on the device could create or delete devices, but couldn't swap a live table. Deleting the device underneath the mounted root filesystem felt like a good way to lose the projector, especially without a recovery partition.

The file was in one extent, though. I could replace its bytes through the physical super device if I kept it exactly the same length. Then the filesystem's metadata wouldn't need to change.

Backing everything up first

I pulled super, both boot slots, vendor_boot, init_boot, vbmeta, vbmeta_system, dtbo, and misc, about 2.85GB in total:

adb pull /dev/block/by-name/super super.img
adb pull /dev/block/by-name/boot_a boot_a.img
# ...and so on for every partition listed above

I got this part wrong the first time. The scratch directory I was pulling into was backed by tmpfs, and the backup filled it. Unrelated processes on my laptop started running out of temporary space even though the disk had plenty of room. I deleted the partial backup and moved it to actual disk storage.

Finding the exact bytes

I extracted the logical system image from the backup and inspected its ext4 metadata:

# system_a's location inside super
adb shell lpdump /dev/block/by-name/super
# → system_a extent starts at sector 2048 in super
#   ⇒ byte offset 2048 * 512 = 1,048,576
 
# extract system_a from the local backup
dd if=super.img of=system_a.img bs=512 skip=2048 count=2812656
 
# find check_adb's inode/extent/block-size inside system_a
debugfs -R "stat /system/bin/check_adb" system_a.img
# → single extent, specific block number
dumpe2fs -h system_a.img
# → block size 4096
 
# offset of check_adb's data within system_a = block# * 4096
# absolute offset within super = 1,048,576 + that offset

The file's absolute offset was the start of system_a inside super, plus its block number times the 4096-byte block size. Those placeholders have to come from the image you're inspecting. They're not values to reuse from this post.

Trying the patch on a backup

The new script enabled ADB, with comments padding it to the original 652 bytes:

#!/system/bin/sh
 
setprop service.adb.tcp.port 3333
settings put global adb_enabled 1
settings put global wifi_adb_enabled 1
start adbd
# persistent adb enable
# persistent adb enable
[... repeated comment line, padded to exactly 652 bytes]

I tried the replacement on a local copy of the system image first. Then I read it back with debugfs and ran a read-only filesystem check:

dd if=check_adb.new of=system_a.patched.img bs=1 seek=<offset> \
   count=652 conv=notrunc
debugfs -R "cat /system/bin/check_adb" system_a.patched.img | sha256sum
e2fsck -fn system_a.patched.img

The read-back hash matched and e2fsck -fn reported no corruption.

Writing it to the projector

Only then did I write to the device:

adb push check_adb.new /data/local/tmp/check_adb.new
adb shell "dd if=/data/local/tmp/check_adb.new \
           of=/dev/block/by-name/super bs=1 \
           seek=<offset> count=652 conv=notrunc"
# 652+0 records in / 652+0 records out

Then cat /system/bin/check_adb still showed the old script. The write had landed, but the mounted filesystem was giving me cached pages. Reading directly from the write offset showed the new bytes:

adb shell "dd if=/dev/block/by-name/super bs=1 \
           skip=<offset> count=652 | sha256sum"
# matches the new file's hash
adb shell "echo 3 > /proc/sys/vm/drop_caches"
adb shell cat /system/bin/check_adb
# now shows the new content

After dropping the page cache, the mounted file showed the replacement too. I rebooted, and ADB came back without the USB marker file.

It did come back on port 5555, even though the patch set 3333. I suspected another persisted setting or a property being written during boot, but never confirmed which. I try both ports when reconnecting now.

Putting the Go server on the projector

Once ADB survived a reboot, I could put the server on the projector itself.

I first tried GOOS=android GOARCH=arm CGO_ENABLED=0, which failed with android/arm requires external (cgo) linking, but cgo is not enabled. But the server didn't use Android APIs. It just needed the Linux kernel underneath, so I built a static Linux/ARM binary:

GOOS=linux GOARCH=arm GOARM=7 CGO_ENABLED=0 go build -o projector-remote ./cmd/server
adb push projector-remote /data/local/tmp/projector-remote
adb shell chmod 755 /data/local/tmp/projector-remote

That ran from /data/local/tmp as the shell user. The early control server sat at about 5MB of resident memory while idle, on a device sharing 1GB of RAM with Android TV. I measured that before adding the media features.

Making the buttons feel quicker

input keyevent got the prototype working, but starting a process for every button press was slow. I looked at the input permissions to see what I could write to directly.

Pointer events

The shell user was in the input group, and the pointer nodes were crw-rw---- root:input. That let me write to the mouse node. I ran getevent -lt while sending REL_X, REL_Y, and SYN_REPORT, and could see the events coming through.

The server finds the node by device name with getevent -i. I didn't want a hardcoded eventN path breaking when the numbering changed after a reboot.

A virtual keyboard through uinput

/dev/uinput was world writable. I wrote a small Go program using UI_SET_EVBIT, UI_SET_KEYBIT, and UI_DEV_CREATE to make a virtual keyboard and send a Home key. Android's focused window moved to the launcher, so it was reaching the actual input system.

That became the deviceio package. It opens one virtual keyboard when the server starts and registers the keys the remote needs.

The kernel codes came from /system/usr/keylayout/Generic.kl. Two mappings were easy to get wrong:

  • Evdev KEY_HOME, code 102, mapped to the text-editing action MOVE_HOME. Android's Home button used 172.
  • KEY_ENTER, code 28, entered text. D-pad center used 353, or KEY_OK.

The remote's table was BACK=158, HOME=172, DPAD_UP=103, DPAD_DOWN=108, DPAD_LEFT=105, DPAD_RIGHT=106, DPAD_CENTER=353, VOLUME_UP=115, VOLUME_DOWN=114, VOLUME_MUTE=113, POWER=116, MEDIA_PLAY_PAUSE=164, with DEL=14 and ENTER=28 added for text entry.

The input-event struct had to match the 32-bit ARM kernel ABI too, including 32-bit time.Sec and time.Usec fields. I put a wire-format unit test around that. Getting it to match my laptop's layout would have corrupted the events on the projector.

For typing and opening apps, I kept local exec.Command calls: input text ... and am start -a MAIN -c LAUNCHER -p <pkg>. Text needed a little bookkeeping because the command only appends. The controller remembers the last value and either types the new suffix or deletes and retypes after an edit. Otherwise, typing into the phone's debounced search field would keep duplicating text on the projector.

Starting the server on boot

I extended the patched check_adb with a hook before its padding, keeping the file at 652 bytes:

[ -x /data/local/tmp/startup.sh ] && /data/local/tmp/startup.sh &

I used the same offline check, raw write, and read-back steps for that change. The script it points to lives on writable /data, so I can change startup behavior with a normal file update now.

The initial startup script was:

#!/system/bin/sh
[ -x /data/local/tmp/projector-remote ] && \
  nohup /data/local/tmp/projector-remote -listen :8080 \
  > /data/local/tmp/projector-remote.log 2>&1 &

I ran check_adb by hand first and got a 200 from /healthz. Then I power-cycled the projector. /proc/uptime showed a fresh boot, the patch was still there, and the server was already answering HTTP requests.

The basic remote could run as shell with this device's permissions. I needed root later for picture controls, to get SurfaceFlinger to redraw immediately after zoom and four-corner geometry changes. Keys and pointer input didn't need that.

Giving up on discovery

I tried an mDNS beacon with github.com/hashicorp/mdns and a _projector-remote._tcp service. It passed the local tests and then didn't work on the Wi-Fi. The multicast advertisements weren't making it to the other wireless clients.

There was a bug in there too: the client wasn't filtering answers by service type, so it could pick up the projector's AirPlay advertisement. I fixed the filter, but the network problem remained.

In the end I reserved a DHCP address on the router. The projector gets the same LAN URL, and Tailscale lets my other devices reach it privately. There's no login in the remote itself, so I keep access on those private networks.

Then I wanted to pick movies from my phone

Once the remote worked, I wanted to browse on my phone, pick a movie, and have it play on the projector. NetMirror didn't have per-title deep links I could use, so I couldn't just tell its app to open the right screen.

I reverse engineered its API by going through the app and capturing its HTTPS traffic with mitmproxy. I worked out the catalog, search, title, episode, and playback calls, then used those for my own client. I disabled the tracking calls too. They weren't needed to browse or play anything.

The phone now talks to a normalized catalog API in the Go server. When I pick a movie or episode, it starts in my own Android player built with Media3 ExoPlayer. The video plays on the projector, and I control playback, audio tracks, subtitles, and quality from my phone. The server remembers where I left off and handles the next episode.

The anime API ended up in there too

I also built an entire anime aggregator API in the same Go server. AniList gives me the title metadata, and provider adapters written in Go find the streams. The phone and Android player use the same catalog and playback interface they already use for movies and shows.

The server does the title matching, episode lists, sub/dub selection, and source recovery. It checks the configured providers concurrently with a deadline and takes the first playable result. If that source fails during playback, it leaves the failed provider out when looking for another one.

I keep watch history against stable release and episode IDs. A provider URL can change without losing my place, and sub and dub progress stay separate. The adapters and HTML extraction all run in Go, so there's no Python or Node runtime to put on the projector.

It's now a Go server, a phone remote, and a player app, all because we forgot to bring the remote. The server, player, deviceio package, patch tooling, and device notes are in the repository.

If I did the partition work again, I'd keep the same checks: map the boot and partition layout first, try writes on a backup, and read the raw bytes back before trusting what the mounted file says.