A display you don’t trust can still tell you the truth, as long as it never learns a secret. -Nor
On October 9, Wes Bos published a post about turning a cheap Amazon picture frame into a family dashboard. The repo that goes with it has the best one-line README I have read this year: “buy a $50 frame, point an agent at this repo and let ‘er rip!” That was all the push I needed.
My work lives in too many places. There are the Claude Code sessions, the four llama.cpp servers in my little home cluster, sixty-odd projects in my git folder, and the weekly plans and status reports for my day job at Reflex Security. To answer one simple question, which is how the week is going, I open a handful of things. I wanted to glance at the wall instead. So I got the frame, and before I let anything rip, I did what a security person does. I read the docs folder.
Most of the docs folder is what you would hope for: careful notes from people who have been taking these frames apart so the rest of us don’t have to. Then there is a file called aimor-remote-root-shell.md, which is not a title you want to see for a device you are about to hang in your office. The frame I bought is a PF1007L, an Allwinner A33 board running Android 6.0.1 with a 10.1-inch touch screen, and its home screen is a vendor app called AiMOR. The upstream researchers pulled that app apart and found that it keeps a plain, unencrypted TCP connection open to a vendor server on port 7806. When the server sends a particular command, the app hands the text of that command to su and runs it as root. The Android build itself is an engineering build signed with test keys, with SELinux turned off. So for fifty dollars you get a nice screen, a stand, Wi-Fi, and a vendor server you know nothing about that can run anything it likes on the frame as root, along with anyone who can get between the frame and that server. To be fair, this isn’t unusual. Last November, Quokka published a long list of the same kind of problems in a different brand of cheap frame, and some of those were pulling down malware at boot. This is just what fifty dollars buys right now.
That is the moment the project stopped being a weekend toy for me. I was going to use this frame, but I was never going to trust it. The way I came to think about it was the way an old counterintelligence officer thinks about a turned agent. It came from the other side. You can put it to work and it can be very useful to you, but you don’t tell it anything you can’t afford to read on the front page, you don’t hand it a key, and you watch every door it can walk through. In practical terms, that meant the frame would hold no passwords, no API keys, no tokens and no documents. A Python service on my Windows workstation would read every source, build every dashboard as plain HTML, and serve it to the frame. The frame’s whole job would be to show pictures of text. Everything else followed from that one decision.
Two days with the frame
I spent most of the first day without changing a single byte on the frame. I ran it through the same process I use on every project now: research, a vision, requirements, success criteria and an architecture, each one reviewed by a few other AIs. They earned their keep. Gemini, GPT-5.5 and Grok all told me my first milestone was too big, and they were right, so I split it. Gemini also noticed that the frame picks a new random MAC address every time it boots, which would have quietly broken any firewall rule I tied to its IP address, and that one would have cost me an evening of confusion later. Mistral hit its rate limit twice and contributed mostly silence. Claude didn’t review its own plan at all, because my Anthropic API account was out of credit. I suppose that is one way to make sure nobody grades their own homework.
The plan that came out of that day had a few rules I wrote down before I plugged anything in. Nothing gets written to the frame without a verified backup, my explicit approval, and a written command that undoes it. The way back into Android’s settings has to be proven with my own finger before the vendor software goes away. And the frame stays on a USB cable until a test shows exactly what it can and can’t reach on the network. I didn’t know yet that every one of those rules would get tested in the next twenty-four hours.
My unit came with January firmware and no debug access over USB at all. Plug it into a PC and Windows sees two empty drives, and that’s it. The way in is a recovery mode baked into the Allwinner chip called FEL. Boot the chip into FEL and it sits there on the USB port waiting for someone to hand it code. I put a tiny FEL loader on a spare microSD card (after backing up the first megabyte of the card, because rules are rules), installed a USB driver for the chip with Zadig, built the sunxi-fel tool for Windows, and built a copy of the U-Boot bootloader with a patch that makes it read-only. That U-Boot goes into the frame’s RAM and nowhere else, and it shows the frame’s internal storage to Windows as a USB disk that can’t be written to. Then I copied all of it, every one of its 15,634,268,160 bytes. The script read each gigabyte twice and compared the two reads before it moved on, and when it was done it hashed the whole image. Then a second script restored that image to a file and checked that the hash matched. I don’t trust a backup until I have restored it.
With a backup I trusted, getting root came down to one line in one file. Android reads a file called build.prop at boot, and one line in it decides what the USB port does. On my frame it said persist.sys.usb.config=mass_storage, and I wanted it to say adb. The word “mass_storage” is twelve characters long. “adb” is three, so I padded it with nine spaces, which meant the file kept exactly the same length and nothing around it moved. The whole change was twelve bytes inside one 512-byte sector, sector 399,221, on a sixteen-gigabyte disk. I didn’t want to edit a filesystem through a bootloader. I wanted to change one sector and be able to prove I had changed only that one. So the script loaded into U-Boot checks the hash of its own payload, the hash of the disk’s first sector and the hash of the target sector against values I already knew, and it refuses to do anything if any of them is off. If they all match, it writes the sector, reads it back, and hashes it again.
The funny part is how it tells you the result. At that point the frame has no screen and no shell, so the script reports by changing the frame’s USB serial number. When the write worked, Windows listed a device whose serial number was A-WRITTEN_VERIFIED-EE1290E6. It is the ugliest status report I have ever designed, and it worked perfectly. Before it ever ran on the real frame, those scripts ran twenty-two tests in a sandbox against a copy of the backup, including the ones where the sector or the disk was wrong and the right answer was to do nothing. Then I approved the one real write. The frame came back up with root access over USB, and it still had it after three power cycles. There was no boot image patch and no vendor trick. It was just twelve bytes, and there is a written command that puts the original sector back.
The escape hatch that almost trapped me
The browser engine on the frame was WebView 44, which is the same engine as Chrome 44 from 2015 and can’t run much of anything written in the last decade. The newest WebView Google ever built for Android 6 is version 106, from September 2022, so that is as far as this frame will ever go. I went and checked what that means today. In CISA’s catalog of vulnerabilities known to be exploited in the wild, 32 Chrome bugs were added after version 106 shipped. Those are bugs attackers actually used, fixed in versions this frame will never receive, and most of them can be triggered by nothing more than a web page. That settled what the browser would be allowed to see, which is exactly one thing: my workstation.
I wrote the kiosk app myself. The upstream repo has no license, so I couldn’t fork its kiosk, and honestly I wanted to know every line of the thing that would own the screen. It locks the browser to one origin and refuses everything else, whether that’s an image, an iframe, a fetch, a new window or a link. I tested it with five off-origin tricks from a test page and then tapped an off-origin link five times with my own finger. All ten were refused, and the workstation never saw a request for any of them. The kiosk also has a way out. If you hold one finger in the top-left corner for three seconds, a small menu appears with Android Settings, Wi-Fi settings, reload, the launcher, and a button back to the dashboard.
That escape hatch is where the second lesson came from, and it took two tries to learn it. The first time I held my finger in the corner, nothing happened, because the long press started a text selection in the browser and swallowed the gesture. Easy enough to fix. The second time, the menu came up, I tapped Android Settings, and I was stuck. The frame has no navigation bar, so there’s no Back or Home button of its own, and the kiosk’s own Back and Dashboard buttons depend on an Android accessibility switch that my reinstall of the app had quietly turned off. I was looking at a settings screen with no way off of it. A few minutes earlier, a scripted test had simulated the corner hold and reported that the menu opened, and it was right. The menu did open. The script just never had to find its way back. I got the frame out of Settings over the USB cable, fixed the kiosk so it turns that switch back on every time it starts, and then did the whole trip again by hand until it worked. If I had disabled the vendor software before that finger test, I would have had a frame stuck in its settings screen with a USB cable as the only way out. That is exactly why that rule was on the list, and it was the first time a rule I wrote on day one paid for itself on day two.
Cutting the handler’s line
Only after my finger had proved the way back did the vendor software go. I disabled the AiMOR app, the vendor’s update service and a factory test app, made my kiosk the home screen, and rebooted. When it came back, no vendor process was running and nothing on the frame was listening on the network at all. The double agent no longer had a handler.
Then it was time to put it on Wi-Fi, and the frame got there before I did. I installed the boot fixes from the upstream project, which pin the MAC address, sort out power and Wi-Fi timing and set the clock, along with a firewall script of my own. When I checked that they had worked, I found the frame had already joined a saved Wi-Fi network all by itself, before I had run the network test I had promised myself it would pass first. It turned out to be the right network, the separate one I keep for IoT devices. But “it turned out fine” is luck, and luck isn’t a control. I turned the Wi-Fi off, finished the router rules and a Windows firewall rule that only lets the frame’s address reach the dashboard port, and only then let it back on. Then I tested from the frame itself. It could reach my workstation’s dashboard port, which answers with a refusal unless the request carries the frame’s token, and it could reach my internal DNS and a time server. It could not reach file sharing or remote desktop on my workstation, the same port on any other machine, my LLM cluster, public DNS, or anything on the internet. The turned agent gets one door, and the door checks its papers.
What the frame does now
By the evening of October 14, about two days after I started, the frame was on its own network, polling my workstation every ten seconds and rotating through its dashboards. One shows my Claude Code activity, read straight from the session logs on my workstation: tokens per hour and per day, which projects they went to, and which sessions are live. Another shows the four nodes of my local cluster, which I named tars, case, plex and kipp because I am exactly that kind of nerd, along with the GPU in my workstation and the day’s cloud usage. There is a clock, a page with the weather, my calendar and the day’s work plan, a health page that pulls from Garmin and Strava and has no tact at all about how long it’s been since my last workout, and a digest of my work week.
That work digest is the newest and least proven piece, and it is built by a headless Claude run. I gave that run read tools only. It can list calendar events, search mail and read Slack channels, but every tool that could send, delete, label or create anything is denied, and it gets no shell. An agent whose job is to summarize my inbox has no business being able to send email from it, so it can’t. Non-human identities deserve least privilege at least as much as human ones, and usually more, because they never get tired and they never ask whether something seems like a bad idea.
The cluster dashboard taught me one last small lesson in honesty. The llama.cpp servers have their metrics turned off, so the token rate on that screen comes from counting slots between polls, and a short request that starts and finishes between two polls is never seen. I put a small line at the bottom of the page that says “token rate is an estimate.” I could have left it off and the number would have looked more authoritative. It also would have been less true. If a number is a guess, the screen should say so.
There was also a joke on the wall I didn’t plan. The first real job this frame ever did was to report how many tokens I had spent building it. By late afternoon on the day it went live, that was 370,000 output tokens on this one project, and the dashboard put it right at the top of the list, unprompted and, I swear, a little smug.
What it taught me
Looking back over those two days, none of the surprises was a disaster, and every one of them could have been in a different order. A frame stuck in its settings screen is an annoyance if the vendor software is still there and a bricked weekend if it isn’t. A frame that wanders onto your network early is fine if the vendor’s back door is already closed and a real problem if it isn’t. What kept each of them small was boring stuff I decided before I started: read the docs folder before the README, restore your backup before you believe in it, write the undo before you make the change, and make a person test anything a person has to use. The other thing I’ll carry forward is to suspect my test before I suspect the network, because I spent an hour blaming a router for a fifty-seven-year-old clock.
The bigger lesson is the one I started with. You don’t have to trust a device to make it useful. You have to decide what it is allowed to know and which doors it is allowed to use, and then you have to test both, with your own hands, before you hang it on the wall. This frame will never be trustworthy. It runs an Android release Google stopped patching years ago and a browser with thirty-two known exploited bugs it will never get fixes for. It doesn’t need to be trustworthy. It needs to show me pictures of text from one machine on my desk, and that’s all it can do.
It isn’t finished. It still has to run for a day and then three days on its own without me touching it, it needs a Codex reader beside the Claude Code one, and it needs a night mode so it stops lighting up my office at three in the morning. The real finish line is two weeks of daily use without a single fix, and I’m not there yet. The traffic is plain HTTP with a token inside its own network for now, and HTTPS with a private certificate authority is on the list.
The code, the frame scripts and the kiosk are on GitHub at rondilley/RAD_PhotoFrame_AI under the GPL-3. The hard part, figuring out this frame in the first place, belongs to the photo-frame-dashboard community. If you build one, read their docs folder first, and then keep your double agent on its own network.
Leave a Reply