Posts | Tags | Archive

Reducing Chromecast Idle Bandwidth

Google's Chromecast is a pretty useful device. It plugs into the HDMI port of a TV and allows users to "cast" content to it via a smartphone, Chrome browser, etc. This content is mostly assumed to be streaming services like Youtube, Spotify, or Netflix, but for someone who doesn't really buy into the whole cloud revolution like myself, it's still perfectly capable of playing local content as well.

The one downside of the Chromecast is that when it's not being used, instead of going into a low-power state and/or turning off the TV, it instead enters Ambient Mode. This shows some useful information overlaid on a changing backdrop of featured photos downloaded from Google Photos. The issue with this is that the images are high-resolution, not cached, and are continually being downloaded 24/7 even when the TV is turned off. Although I haven't personally measured it, the general consensus seems to be that it uses around 15GB of data per month from just being plugged in.

What I'll be going over here is reducing the data the Chromecast uses by configuring it to only download some tiny black 1x1 px images to use as a backdrop.

The short version

  • Make a tiny black PNG (pixel.png)
  • Make a slightly different tiny PNG to avoid deduplication (pixel_alt1.png)
  • Upload both to an album in Google Photos
  • Mark both images as favorites
  • Configure the Chromecast to only pull images from your specific album every 10 minutes

If you like details, keep reading...

Making a small image

I ended up creating a 1x1 solid black image in GIMP, saving it as a PNG, hacking out as much data as possible using a hex editor, then using pngcrush to attempt to optimize the data that was left.

As I was working on the PNG file, I used a small script based on the Python construct library to visualize the chunks and structure of it so I could figure out what to cut.

To install construct and download the example PNG specification:

1
2
3
4
python3 -m venv .venv
source .venv/bin/activate
pip install construct
wget "https://raw.githubusercontent.com/construct/construct/abd48c4892ceddc60c11d25f4a955573e2c61111/deprecated_gallery/png.py"

Create pngview.py:

1
2
3
4
5
6
7
import png  # the png spec downloaded above
import sys

with open(sys.argv[1], 'rb') as f:
    data = f.read()
    print("Size:", len(data), "bytes")
    print(png.png_file.parse(data))

Running it on the PNG exported from GIMP using the default settings gives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
$ ./pngview.py pixel.png
Size: 146 bytes
Container: 
    signature = b'\x89PNG\r\n\x1a\n' (total 8)
    image_header = Container: 
        length = 13
        signature = b'IHDR' (total 4)
        width = 1
        height = 1
        bit_depth = 8
        color_type = (enum) truecolor 2
        compression_method = (enum) deflate 0
        filter_method = (enum) adaptive5 0
        interlace_method = (enum) none 0
        crc = 2423739358
    chunks = ListContainer: 
        Container: 
            length = 9
            type = b'pHYs' (total 4)
            data = Container: 
                pixels_per_unit_x = 11811
                pixels_per_unit_y = 11811
                unit = (enum) meter 1
            crc = 2024095606
        Container: 
            length = 7
            type = b'tIME' (total 4)
            data = Container: 
                year = 2018
                month = 12
                day = 11
                hour = 5
                minute = 2
                second = 19
            crc = 904567710
        Container: 
            length = 25
            type = b'tEXt' (total 4)
            data = Container: 
                keyword = u'Comment' (total 7)
                text = b'Created with GIM'... (truncated, total 17)
            crc = 1468075543
        Container: 
            length = 12
            type = b'IDAT' (total 4)
            data = b'\x08\xd7c```\x00\x00\x00\x04\x00\x01' (total 12)
            crc = 657729290
        Container: 
            length = 0
            type = b'IEND' (total 4)
            data = None
            crc = 2923585666

After removing the unneeded pHYs, tIME, and tEXt chunks using a hex editor, it looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ ./pngview.py pixel.png 
Size: 69 bytes
Container: 
    signature = b'\x89PNG\r\n\x1a\n' (total 8)
    image_header = Container: 
        length = 13
        signature = b'IHDR' (total 4)
        width = 1
        height = 1
        bit_depth = 8
        color_type = (enum) truecolor 2
        compression_method = (enum) deflate 0
        filter_method = (enum) adaptive5 0
        interlace_method = (enum) none 0
        crc = 2423739358
    chunks = ListContainer: 
        Container: 
            length = 12
            type = b'IDAT' (total 4)
            data = b'\x08\xd7c```\x00\x00\x00\x04\x00\x01' (total 12)
            crc = 657729290
        Container: 
            length = 0
            type = b'IEND' (total 4)
            data = None
            crc = 2923585666

At this point, all that's left to optimize is the image data itself. This is where pngcrush shines. After running pngcrush -brute -ow pixel.png we see:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ ./pngview.py pixel.png
Size: 67 bytes
Container: 
    signature = b'\x89PNG\r\n\x1a\n' (total 8)
    image_header = Container: 
        length = 13
        signature = b'IHDR' (total 4)
        width = 1
        height = 1
        bit_depth = 8
        color_type = (enum) greyscale 0
        compression_method = (enum) deflate 0
        filter_method = (enum) adaptive5 0
        interlace_method = (enum) none 0
        crc = 981375829
    chunks = ListContainer: 
        Container: 
            length = 10
            type = b'IDAT' (total 4)
            data = b'\x08\x1dc`\x00\x00\x00\x02\x00\x01' (total 10)
            crc = 3486004709
        Container: 
            length = 0
            type = b'IEND' (total 4)
            data = None
            crc = 2923585666

The 2 byte savings in the data seem to have come from setting the color_type to greyscale instead of truecolor. To view what actually changed in the data, we can un-deflate it using Python:

1
2
3
4
5
6
7
8
>>> import zlib
>>> # before pngcrush (12 bytes)
>>> zlib.decompress(b'\x08\xd7c```\x00\x00\x00\x04\x00\x01')
b'\x00\x00\x00\x00'
>>> # after pngcrush (10 bytes)
>>> zlib.decompress(b'\x08\x1dc`\x00\x00\x00\x02\x00\x01')
b'\x00\x00'
>>>

This makes sense since, according to the PNG spec, when using truecolor, each pixel is an RGB triple, requiring 3 bytes. For greyscale, only a single byte representing luminance is needed. The first byte is the filtering method, which isn't relevant here since we only have a single pixel.

Interestingly enough, in both cases we would actually be much better off if we could opt to not use compression, but alas, the spec does not allow for anything except deflate.

But I digress, we now have a black 1x1 px PNG image that's just 67 bytes (pixel.png).

Generating multiple unique small images

So now we have the small PNG we want to display. Since the Chromecast's ambient mode requires at least 2 different images in an album to cycle through, all we need to to do is upload 2 copies of this PNG and we're done right? Almost.

Since Google Photos will automatically deduplicate uploaded images, we need to find a way to make the second image slightly different. Normally this would involve tweaking a comment or something, but in this case, the image has already been stripped down to its bare essentials.

My strategy was attempt to abuse the data compression to see if I could generate an image that compressed the same data into the same number of bytes, but differently. Fortunately, pngcrush can be told to use specific compression strategies (there are currently 177 of them). My hope is that at least one of these will achieve the same results, but in a different way. For starters we'll try the first 9:

1
2
3
for x in $(seq 9); do
    pngcrush -m "$x" pixel.png pixel_alt${x}.png;
done

A quick ls -l reveals that all of the pixel_alt*.png files are still 67 bytes. Now we just need to find one that has different data. sha1sum is the perfect utility for this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$ sha1sum pixel.png pixel_alt*.png
d99a9d63b1cd9e4b3f823d4d03144ccd95328f48  pixel.png
7487dcce2b2bb81a442faf139b0a547bf070d5e2  pixel_alt1.png
7487dcce2b2bb81a442faf139b0a547bf070d5e2  pixel_alt2.png
7487dcce2b2bb81a442faf139b0a547bf070d5e2  pixel_alt3.png
c3ea09bfcfcb36ce22d3f19eacada359f3984ed1  pixel_alt4.png
c3ea09bfcfcb36ce22d3f19eacada359f3984ed1  pixel_alt5.png
c3ea09bfcfcb36ce22d3f19eacada359f3984ed1  pixel_alt6.png
c3ea09bfcfcb36ce22d3f19eacada359f3984ed1  pixel_alt7.png
c3ea09bfcfcb36ce22d3f19eacada359f3984ed1  pixel_alt8.png
d99a9d63b1cd9e4b3f823d4d03144ccd95328f48  pixel_alt9.png

pixel_alt1.png looks like a good candidate, let's see what changed:

1
2
3
4
5
6
7
$ diff <(./pngview.py pixel.png) <(./pngview.py pixel_alt1.png)
20,21c20,21
<             data = b'\x08\x1dc`\x00\x00\x00\x02\x00\x01' (total 10)
<             crc = 3486004709
---
>             data = b'\x08[c`\x00\x00\x00\x02\x00\x01' (total 10)
>             crc = 1648381800

There's the same amount of data, but it's different. Let's check if it decompresses to the same image data:

1
2
3
4
5
6
>>> import zlib
>>> zlib.decompress(b'\x08\x1dc`\x00\x00\x00\x02\x00\x01')
b'\x00\x00'
>>> zlib.decompress(b'\x08[c`\x00\x00\x00\x02\x00\x01')
b'\x00\x00'
>>>

Yep, this means that pixel_alt1.png is the exact same image with the exact same size, but won't be deduplicated when uploading it to Google Photos since the compressed data is different.

Final touches

Now that we have 2 different tiny images, we can upload them to Google Photos and put them in an album so they can be pulled down by the Chromecast. Something to note is that you may have to mark the images as favorites to get them to be displayed. There seems to be some sort of AI-fueled "we know better than you" algorithm that initially refused to display my images until I starred them.

Now just set your ambient mode to display the album with your images in it (or your favorites album), set the slideshow speed to its maximum value (change image every 10 mins), and you're done.


Password recovery from an Alcatel-Lucent Cellpipe 7130

Background

In my current rental situation, the landlord provides internet included with the rental. Since it's fast enough and has enough bandwidth this isn't an issue at all. The only problem is that the modem provided by the telco is an ancient Alcatel-Lucent Cellpipe 7130 5VzA2001 modem/router combo. It's running firmware version 1.0.4.4R8-wh, released on 2012-05-08 16:30 (there are no updates, I checked).

Since the Cellpipe only has 4 x 10/100 LAN ports and terrible WiFi performance, I would much prefer to use my own equipment to perform the routing and just use the Cellpipe 7130 as a WAN gateway. Unfortunately, for some reason, there is no way to turn off the routing aspect of the Cellpipe and just use it as a modem (aka bridge mode). There is also no way to turn off DHCP functionality on it.

My current hacky solution to these problems is to let my router get an IP from the modem/router via DHCP, then set that IP as the DMZ host on the Cellpipe. This effectively forwards all TCP and UDP ports to the router, making it seem like it's the boundary device for the network. Luckily, my router always seems to request the same IP no matter what so this continues to work even if both devices are power cycled.

Since this is not an ideal situation, I'd like to replace the Cellpipe with a more performant modem without any of the routing overhead. To do this, I just need to port the configuration details from the Cellpipe (like the username and password for the upstream PPPoE connection) over to a new modem. Unfortunately, there's nothing in the web interface that will allow me to see the password and there's no option to do a configuration backup. I also can't just call up the telco since it's the landlord's account.

This means we're stuck, right?

Digging into the Cellpipe

Doing a quick CVE search for the Cellpipe only turns up CVE-2015-4586 (a CSRF vulnerability) and CVE-2015-4587 (an XSS vulnerability), both of which aren't useful in this case. Oh well.

Seeing as the Cellpipe 7130 manual(PDF) mentions various functionality that my device doesn't seem have, my hypothesis is that there might be a configuration backup/restore function somewhere, just not exposed in the UI. With that in mind, I went trawling through the HTML and JavaScript files the web UI served up. I found:

The color 'red' is commented out and redefined as white. This suggests that they could be hiding error messages by just making them the same color as the background:

1
2
//var red    ='#FF0000';
var red    ='#FFFFFF';

Debugging in production builds with commented out alert statements:

1
2
//alert(isRouter);
//alert(isAPmode);

Various comments indicating lack of source control:

1
2
3
4
5
6
7
8
//Jamie hide 20111109
/*if(menuItem=='HPNA')
{
    printMenuItem('hpna.html', 'HPNA', white, darkBlue);
}else{
    printMenuItem('hpna.html', 'HPNA', black, blue);
}
*/
1
2
//fanny add 2011/02/22
printMenuItem('wifi_statistics.html', 'WLAN Statistics ', black, white);
1
2
3
4
5
<!-- jonathan 2004.04.07    Begin  -->
<form name="logoutForm" method="post">
    <input type="hidden" name="logoutMsg">
</form>
<!-- jonathan 2004.04.07    End  -->

These all seem to allude to shoddy release processes, meaning that it's highly likely that per-model customizations were rushed UI-level hack jobs on top of the current release and not maintained branches where actual functionality was changed.

Since a configuration backup will include the PPPoE username and password, let's do a search for "config". Sure enough:

1
2
3
4
5
6
7
8
printMenuSection('util_main.html', 'Utilities', white);
printMenuItem('lang_set.html', 'Language Setting', black,white);
printMenuItem('util_reboot.html', 'Reboot Gateway', black,white);
//printMenuItem('util_reservice.html', 'Restart Service', black,white);
printMenuItem('util_factory.html', 'Restore Factory Defaults',black, white);
//printMenuItem('util_cfgstore.html', 'Configuration Store', black,white);
//printMenuItem('util_cfgrestore.html', 'Configuration Restore', black,white);
printMenuItem('util_webfirmware.html', 'Web Firmware Upload', black,white);//Jamie add back 20111109

Visiting http://<router_ip>/util_cfgstore.html shows a nice "Store" button that downloads a text file containing 771(!) key/value pairs, including the username and password for the router. Success!

Future plans

There are a ton of other options in the downloaded config file, some of which look like they enable bridge mode. However, flipping those settings on and applying the new config using http://<router_ip>/util_cfgrestore.html didn't seem to change anything. I'm going to keep messing with it while I look into sourcing a better modem to use my newfound credentials with.

Longer-term, the plan is to transition to a dedicated modem that provides WAN access to a low-power computer running pfSense for firewall and routing duties. From there, an unmanaged switch (possibly injecting PoE) can provide access for enough dumb wireless APs to bathe the house in WiFi, as well as wired hookups for the devices that don't move around too much or need the throughput. For now though, I'll settle for replacing the modem.


Forwarding spam with Gmail

With multiple Gmail accounts, it's often easier to forward all emails to a single main account that you actually check. This can be accomplished by setting up a forwarding account in Gmail's settings.

However, something that isn't immediately obvious is that messages that are considered spam are never forwarded. While Gmail's spam detection is extremely good, it's not perfect. There have been times where emails that I actually wanted have ended up in the spam folder and weren't forwarded to my main account.

To fix this, we want to tell Gmail to not flag anything as spam so it will be forwarded along.

Note

This will not cause spam to appear in your inbox. It bypasses the spam filters so the email is forwarded properly, but the account it's forwarded to will also flag the email as spam. The end result is that that the email will end up in the spam folder of your main account (where you can actually look at it) instead of not being forwarded from the original account at all.

We can do this with a filter that matches is:spam and applies the option "Never send it to Spam". Unfortunately, Gmail's interface makes this task much harder than it needs to be.

  1. Open Gmail and do a search for is:spam. Notice that Gmail autocorrects it to in:spam.
  2. Click the dropdown arrow on the right side of the search box to bring down the advanced options and click "Create filter with this search". Hit "OK" on the warning box that pops up. Notice that the filter has been autocorrected again to label:spam.
  3. In the URL you should see something like #create-filter/has=label%3Aspam. Change label to is in the URL and hit enter. It should modify the text in the box without changing anything else.
  4. Check the "Never send it to Spam" checkbox and hit "Create filter". You'll see the text get autocorrected to in:spam again, but if you check in Settings -> Filters and Blocked Addresses you should see the correct is:spam filter.

© Carey Metcalfe. Built using Pelican. Theme is subtle by Carey Metcalfe. Based on svbhack by Giulio Fidente.