Back in January 2020 I shared a tweet demonstrating a Twilio integration with my Meural Canvas digital picture frame.

In the months since, guests have had a lot of fun with it and it’s been awesome seeing occasional surprise memories from friends pop up. That said, building that integration wasn’t the most straightforward task. I documented the journey (and failures) I took to arrive at the ultimate solution, with the intention of illustrating that reverse engineering is largely about persistence, and full of surprises. Most notably, I did not expect to encounter a dynamically generated virtual machine that generates and injects the headers required to validate authentication requests.

Update: Since posting, folks have noted this exact same technology is in use at Nordstrom ( and Target ( The solution appears to be Shape Security.

Testing the waters

From a glance at my Google Wifi app I could see that the Meural connected to my network as, so the first port of call was a port-scan! nmap to the rescue.

$ nmap -A -T5 -Pn
Starting Nmap 7.80 ( ) at 2020-07-25 16:39 CDT
Warning: giving up on port because retransmission cap hit (2).
Nmap scan report for
Host is up (0.0100s latency).
Not shown: 912 closed ports, 87 filtered ports
80/tcp open  http  Apache httpd 2.4.18
|_http-server-header: Apache/2.4.18 (Ubuntu)
|_http-title: 403 Forbidden
Service Info: Host:

Service detection performed. Please report any incorrect results at
Nmap done: 1 IP address (1 host up) scanned in 16.34 seconds

Unfortunately limited surface area, and no ssh, bummer! But… there’s a webserver, cool! Let’s try making a request.

Meural 403

As nmap hinted, hitting the device yields a 403, but it’s good to know it’s running Ubuntu and Apache! Great! I tried a cursory search of the Apache Vulnerabilities, but it yielded nothing exciting for 2.4.18. Time to start fuzzing, I chose wfuzz as a lightweight simple utility to explore what may be exposed. wfuzz allows you, among other things, to supply a wordlist that it will substitute into a supplied URL which in our case is to attempt to find endpoints that return a non-40x http response on the device. Many great wordlists exist but of the few I tried megabeast yielded the most interesting/useful results.

$ wfuzz -w wordlist/general/megabeast.txt --hc 403

Total requests: 45459

ID       Response   Lines  Word   Chars     Payload

000009226:   301    9 L    28 W   328 Ch    "compile"
000016580:   301    9 L    28 W   326 Ch    "files"
000017134:   301    9 L    28 W   326 Ch    "fonts"
000034194:   200    17 L   42 W   633 Ch    "remote"
000039179:   301    9 L    28 W   327 Ch    "static"
000041161:   301    9 L    28 W   330 Ch    "templates"
000043724:   301    9 L    28 W   315 Ch    "vendor"
000043899:   301    9 L    28 W   326 Ch    "video"

Total time: 128.6474
Processed Requests: 45459
Filtered Requests: 45451
Requests/sec.: 353.3610

Running wfuzz showed me that the Meural exposes a remote utility — which, as far as I can tell, they don’t actually tell you about. Hmm. Accessing the remote utility at returns the following page:

meural remote

I poked around the remote utility for a couple of hours, but that proved fruitless; I was unable to find a security hole (kudos to the team!). While you can control the device with the remote utility, I didn’t want to expose my local network to connect it to Twilio. Since the device has both a cloud interface and an iOS application for remote management, I figured there was probably a tunnel connecting the picture frame to Meural’s backend. I decided to see if I could use their web interface to build my SMS application. Before I dove into that, I wanted to see if I could intercept that tunnel. Not only could this allow me to intercept or inject my own commands, it’d allow me to get a better understanding of how the device operates and even potentially to understand (and try to abuse) the software update process.

dnsmasq to the rescue!

I started by setting up dnsmasq on my laptop so I could hijack and forward DNS queries. I then configured my router to use my laptop as a DNS server..

Jul 24 00:00:00 dnsmasq[2783]: query[AAAA] from
Jul 24 00:00:00 dnsmasq[2783]: cached is <CNAME>
Jul 24 00:00:00 dnsmasq[2783]: cached is NODATA-IPv6
Jul 24 00:00:00 dnsmasq[2783]: query[A] from
Jul 24 00:00:00 dnsmasq[2783]: cached is <CNAME>
Jul 24 00:00:00 dnsmasq[2783]: cached is
Jul 24 00:09:45 dnsmasq[3045]: query[AAAA] from
Jul 24 00:09:45 dnsmasq[3045]: forwarded to
Jul 24 00:09:45 dnsmasq[3045]: reply is <CNAME>
Jul 24 00:09:45 dnsmasq[3045]: reply is
Jul 24 00:09:45 dnsmasq[3045]: reply is
Jul 24 00:09:45 dnsmasq[3045]: reply is
Jul 24 00:09:45 dnsmasq[3045]: reply is
Jul 24 00:09:45 dnsmasq[3045]: reply is <CNAME>

After changing the DNS server, I could see that the device tries to hit and I wanted to see what requests the device was making to those endpoints. To do that, I attempted to coerce the device into hitting my webserver instead. I started by changing my dnsmasq configuration to redirect requests pointed at and to mamps.laptop.


I then generated a self-signed certificate using openssl, added it to my Keychain, and set up an HTTP server to dump the raw requests to stdout.

$ sudo openssl genrsa -out meural.key 2048
$ openssl req -new -x509 -key meural.key -out meural.crt -days 365 -subj /
$ security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain meural.crt
$ http-server --ssl --cert meural.crt --key meural.key --port 443 --log-ip -a

Finally, I made the target CNAME resolve to my laptop:

$ grep mamps /etc/hosts mamps.laptop

After restarting dnsmasq I could see now resolving to my laptop:

Jul 24 00:11:24 dnsmasq[3571]: query[AAAA] from
Jul 24 00:11:24 dnsmasq[3571]: config is <CNAME>
Jul 24 00:11:24 dnsmasq[3571]: query[A] from
Jul 24 00:11:24 dnsmasq[3571]: config is <CNAME>
Jul 24 00:11:24 dnsmasq[3571]: /etc/hosts mamps.laptop is

Unfortunately (but unsurprisingly) the device refuses to connect. I suspect it’s because the Meural either implements certificate pinning or it (rightfully!) rejects the self-signed certificate I provisioned. I tried doing a similar thing for the ELB without success. The Meural developers win again! Feeling a bit disappointed, I decided to switch gears and focus on

Chartering new territory

As previously mentioned, the Meural provides both a web interface and iOS application that lets you purchase art, upload photos and configure the device. The web portal leverages, and I figured I probably could too.

meural portal

Before I could start leveraging the same APIs used in this portal, I need to obtain an access token. Let’s take a look at the request made upon login using the Chrome network inspector:

meural login

Viewed as a curl request, trimmed to the meaty bits:

curl '' \
  -H 'x-lz0ckp04-f: Ay4QKYhzAQA....' \
  -H 'x-lz0ckp04-b: -jdr95b' \
  -H 'x-lz0ckp04-z: p' \
  -H 'x-lz0ckp04-a: 1m3FynGGY8zW3U.....' \
  -H 'x-lz0ckp04-c: A876J....' \
  -H 'cookie: ...' \
  --data-binary '{"emailReset":"","serialNumber":"","sessionID":"","devtypeid":"","xtoken":"<redacted>","tokenType":"","userAgent":{"browser":"Chrome","platform":"MacIntel","appCodeName":"Mozilla","appName":"Netscape","vendor":"Google Inc.","product":"Gecko"}}' \

A few things here stood out to me. First of all, there’s a lot of content passed via those mysterious x-lz0ckp04 headers. Second, it appears my password is encrypted and passed within the xtoken field of the request body. I wanted to find out what those headers meant and how my password was encrypted, so I dug into the admin application code. The admin application is an Angular app that, luckily, does not have minified or obscured source code (it even includes comments!). I started by digging into the LoginController (

A quick look through the code shows that this is our target to recreate a token:

let xtoken = getEncrypt();
$scope.credentials.xtoken = xtoken;
$scope.tempCred.emailReset = $scope.credentials.emailReset;
$scope.tempCred.serialNumber = $scope.credentials.serialNumber;
$scope.tempCred.sessionID = $scope.credentials.sessionID;
$scope.tempCred.devtypeid = $scope.credentials.devtypeid;
$scope.tempCred.xtoken = $scope.credentials.xtoken;
$scope.tempCred.userAgent = globalServices.userAgentString();

  method: 'POST',
  url: basePath + "/mfa/authWAC/" + redirect,
  data: $scope.tempCred,
  timeout: 30000
}).then(function successCallback(response) {

And associated getEncrypt function:

var getEncrypt = function () {
  let epochTime = Math.round((new Date()).getTime() / 1000);
  let enData = $ + " ######-----##### " + $scope.credentials.password + " ######-----##### " + epochTime;
  let encrypt = new JSEncrypt();
  var publicKey = globalServices.getPublicKey();
  return encrypt.encrypt(enData);

By tracing it through to globalServices we can see that the public key is a constant. It’s a base64 encoded 2048bit DER key which we can verify/decode here.

getPublicKey: function(){
  var publicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6oLssFCWGau5e4HtxNfeb0Az8phTtcxiAUb+Whtb6asLUMiHKDwbXKUf6GmsUKkJceidR4n2x17SxmEl8us9hda3X9a53kzOEQLgb8G5sKE0jIc6oCXurvQEP3F9t4lxWRjesDU9cbH8eZmpsQYmXgAr+lFj5xDmCkbS7XF0ejfxOqF9VcwUWzCCj+WTiFYZt+C7Ujz1YNubWAqWLHCay8SwdKtF5/BUYiztGrwUyXULhCpHd4blL8zU7vPeAMCqvDuV/a+J5ZQTJwO41iv3X+n7l7inme+84jfbc+TC4pZTG2CU8EHm+rpkBxng2oiUQn/ok6dUPjVkNCfdAEpbZQIDAQAB";
  return publicKey;

Using these pieces of information, I tried to make my own token and substitute it into the request to see if it would work.

const JSEncrypt = require('node-jsencrypt');
const publicKey = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6oLssFCWGau5e4HtxNfeb0Az8phTtcxiAUb+Whtb6asLUMiHKDwbXKUf6GmsUKkJceidR4n2x17SxmEl8us9hda3X9a53kzOEQLgb8G5sKE0jIc6oCXurvQEP3F9t4lxWRjesDU9cbH8eZmpsQYmXgAr+lFj5xDmCkbS7XF0ejfxOqF9VcwUWzCCj+WTiFYZt+C7Ujz1YNubWAqWLHCay8SwdKtF5/BUYiztGrwUyXULhCpHd4blL8zU7vPeAMCqvDuV/a+J5ZQTJwO41iv3X+n7l7inme+84jfbc+TC4pZTG2CU8EHm+rpkBxng2oiUQn/ok6dUPjVkNCfdAEpbZQIDAQAB';

function getEncryptedPassword(email, password) {
  const encrypt = new JSEncrypt();
  const epochTime = Math.round((new Date()).getTime() / 1000);
  const enData = `${email} ######-----##### ${password} ######-----##### ${epochTime}`;
  return encrypt.encrypt(enData);

console.log(getEncryptedPassword(process.env.MEURAL_EMAIL, process.env.MEURAL_PW));
$ MEURAL_EMAIL=<redacted> MEURAL_PW=<redacted> node token.js

$ curl -X POST '' --data '{"xtoken": "YA6Cb62u2TVehIv+4k...", ...}'

Shucks - I’ll admit that I wasn’t too surprised that didn’t work. Next, I used the Chrome debugger to set a breakpoint in the getEncryptedPassword function so I could double check that I was passing in the same arguments to generate a valid xtoken. I was. I then tested injecting one of my generated tokens, that worked too. It was clear I needed to take a closer look at the x-lz0ckp04-* headers.

Surprise! It’s DRM.

I tried to hook XMLHttpRequest, but it seemed like the app was aware of that; it would stop setting said headers 😭! However if I just hooked XMLHttpRequest.prototype.setRequestHeader, it seemed unaware — a much needed, though small, win.

meural login debugger

Not too far into meddling, I managed to trigger a 500 Internal Server Error and get a nice stacktrace. It’s looked like it was a node/express backend built on top of the Sails.js framework (a fellow Austin company!). I later learned this 500 happens if you don’t supply the validDomain cookie.

  "stack": "TypeError: Cannot read property 'includes' of undefined
  at Object.validateReq (/cam_server_web/api/services/UtilityService.js:282:45)
  at Object.wrapper [as validateReq] (/cam_server_web/node_modules/@sailshq/lodash/lib/index.js:3282:19)
  at Object.auth (/cam_server_web/api/controllers/MfaController.js:106:20)
  at wrapper (/cam_server_web/node_modules/@sailshq/lodash/lib/index.js:3282:19)
  at routeTargetFnWrapper (/cam_server_web/node_modules/sails/lib/router/bind.js:181:5)
  at callbacks (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:164:37)
  at param (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:138:11)
  at pass (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:145:5)
  at nextRoute (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:100:7)
  at callbacks (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:167:11)
  at alwaysAllow (/cam_server_web/node_modules/sails/lib/hooks/policies/index.js:224:11)
  at routeTargetFnWrapper (/cam_server_web/node_modules/sails/lib/router/bind.js:181:5)
  at callbacks (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:164:37)
  at param (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:138:11)
  at pass (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:145:5)
  at nextRoute (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:100:7)
  at callbacks (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:167:11)
  at _sendHeaders (/cam_server_web/node_modules/sails/lib/hooks/cors/to-prepare-send-headers.js:91:7)
  at routeTargetFnWrapper (/cam_server_web/node_modules/sails/lib/router/bind.js:181:5)
  at callbacks (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:164:37)
  at param (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:138:11)
  at pass (/cam_server_web/node_modules/@sailshq/express/lib/router/index.js:145:5)",
  "message": "Cannot read property 'includes' of undefined"

So, holy smokes, the team really turned off easy mode for this part. I assumed our x-lz0ckp04-* headers were some kind of XSRF token. Oddly, I could neither locate them in the html source nor was I seeing them sent back over the wire. After not finding an XHR request anywhere, I turned to mitmproxy to obtain a packet capture (pcap). When I searched the pcap, the x-lz0ckp04 string was nowhere to be found 😕. However, I did locate one of the other strings (AyitXI1zAQAAjKszx1DbNZWmK1wxfkqy8tzDqhtPmqrJz6......vAAAAAA==) in 🙀. If you refresh this URL a few times you’ll see it is dynamically generated.

var H=["p_hRmNzMNq9Q0FHrthtFY9uL","b-df3bfDD4RBqg","oQOTfUc02BnwbuMeBa2x521TBA","LN2","Sutxu5TsAPIMuQPN","q60k5vjiGY1GpSz-lzg","M3DpGD11vHW7M4hrTpTklg","wVHiKQx4mWg","R54r5v3tRbRQ5mKW1nBkVtbzjDae7xcZ","GUviGDYtp1mE","W0ngf2ksrB2aSQ","bhumWxxFuXTDNsxn","ZFriUipHjii1QQ","Lz63eFgpywDpdtIrIg","qVf0eHgvnRSDWNI7demY_G5YH5oVUKE","RJt2x_7Kc9NYzkXLu2NXE5rz7WettGgFY96dweVe7p2SkohSQ37EWQI","KedPlo_EBclqwUc","_XPUIh0Qz0eKBA","7","Au8O45O6SatxoyaT3FsjOg","pgmlS3dso3L2fOMtMeL6vT8BGdc","kn_SCEIfsQ-nQsA","93","jn_RGA1KpCnuUMhvK6msxCVPRJt3Ksw","hA_he18KnX26","fkf4PD0m","XRmoZixbvF6QCA","now","aMhdp430EuonsyvZ1SpQOLCdjzuoyUVeeQ","UfRAz40","SJUrl-o","Gt9xnrG-YaZiww","RAmmdEYV7TqxLLo","gepDrIs","09ZUq4XfOKJD","pI4_78eAZw","T_Zk7YTGE4kX6leT","8UHyGkk","IFv_Ymd45kL5S-9y","OdAykYaPPu0Pmw","aC2kd2909163OrsXM5PcuQ","qo1S2orVMfoMyy-iqA","11","XaBP6-fxWQ","112","kKwJwZ-eeew3lQms5F4GPYnPuE-c3y8-FPrHmP1yq8H_0e0DSlCccxD48Rb0AOcqTII","mp4","o_0","charCodeAt","ip","RhaRSnYV_2GqD7piSPT_jSgAAMAqaKaVqAhW","w7pUu8XrQJAuuC6xmF8rP-HW","kGW7XV1Mk1c","3KpUsY0","uKkxzuyHd45Yw0Swu14vS9nn6kTaqCs5HA","filter","OKYYiPXMGOJ_pwXn6Q","32","jLhI9I-TKeZg8lm3hwJFV9yShQ","log","19","e3XZXkEC_USy","JtpYjbP6Nv8Z4k8","Ie4mjZuPJd1-gDXG","82","kULoOjRs3Br-","wss","sshyt6nvZs8S7A","hF35VS52mie4XQ","a9hnk97-Z-FL1VPqoRtaRZ2qhWvr6HYqctSQ","48Ngk8SGHQ","s7cDqdjxJ5ZL00c","T6kVy4Odf-p_gVK6tA5aPJWRuAjDhT51","gi","CK0HrYyKRogDgg","from-page-runscript","uhC0dXJy2g","8wr5TE5CiQ","47","13","YP14qr3-","Xt9tz_TbYNJ8_H8","nGXaHipd5G-y","rQGvR0ISonuCTg","QVbfKgVyzD3i","GdxB05ibeqAjiWujv0JGc5mPkRabwlM4ENTZy69A-Iin1vA","vX_3JyEKkTw","byLSeG9-zhTqUvYQAIHK534LDck","DLUd09WTePl3hCA","y2rhIzFhnATeZv8WIZGb7lxYfLBcHOLq0G00T3awch9uBSjElJM","tCXSJEZe1QunKLM","Add","TX38LRhesiQ","shKydzd-igLIZPEXH7OnwnUs","BQT1AGRB4TSPBJ8xMfmHm0xsPOUWZ-OH","nSHRKgUAtHyrL6INKN-lrWg","4uFqr4rrMv0","cYshtqj-Ut9OkSfptR1VJKs","4IwQ2ObIY-Z-gQTaokJML9ei_VuNiF8k","BRu6bn8G3gLZQsM","OjWZWGoqyhs","2l7OLApst3msNKRQTL4","3sh6htn0Y_RJxnjo","M-Vys7DALuEixw","98","r5MRxuqiZe4LqA","arr","fFjlFD1bonWAJoZ9Kw","wUe4GFR_3xSOBI9W","1aES-966Qr5p_X-Lhi1XZOvU13THkRkHPaaClI8","Vm_FBlcPrhc","fBelN3pmtkg","7220QyofrA","Z1voBytI7ALV","-GPtGjBQ9CHHA9xPJ8HfllsGEQ","Object","PxX_CWFW7jWPH4kwO_mGjU16J_YXYeCPhlZ5dHuNTDRNXQXVzOREyp1Dbug0qEqTgCBl-dQahALrgD-G","xYJBpNGxR81usiWetA","cB6gcQJ98nPaCA","_BqUQT5P0XuAGw","blT4axR2gCnuGvkUJsO1","aPUO4o-hBg","90","28","75","qUCkVXxik2j_feAhKg","nedWicPGMa95wWbopzBEZcqW","Z3X9RjBOs1GBEYE4fIzthAswL5sT","num","jj6mZ1QT-B3FSfk3Zp-t9EVL","yvhjp7e8I75AwmU","k8lq_uCnFbAb","SNF47P2nW_kMpg","jKIS7ciOeA","nKwy6M8","aw6CWHxWwH7YFYVALA","NpY1o9yBUPQm2w","CW3LKhEc5F2CHplsQYXCiBp3ZOcHaec","]","hroGyNSRYuQ5mg6izV5GIg","16","ftp","r9Jl8eSgGJwQ3EOq-mYFZdrU","Y0y6R3E","r1_VYiAW-yC7","eiCuQk4k0Vq9N6Zcecc","0AGqJQhYyQ","ycU5i52sIvEBjRrj2GMuH4P_","TC76GHBl","dRSQTkdUulOzHvY6Pc3O","Qt9FgJr2LtMOi0GEqVElaw","o6clr9nkX59D","bclmqK4","y","OFb7dnc9kiOnRdg2","b-VblM-3EqMQ5g","X2rND1IG-n-dFJRn","zR_FYxdDqAngY8YBew","12","poY97-i2VrB91Wo","2d","wrQOjMWYS9R3vAXlzkY","id","bZk08aa4T8kW","N48l4oU","1VjvNm0d_AXMXZt8BpSzlA","b_BMpufOR_Ru8WjZgCd0RqCCqQ","88","68","t60rxuuINPkzxVSV6zo","VOxJrovmO8o1vTE","62","TOxtrp8","-5JpyY2nCslG3V-T","mVP2OxdL618","map","woU40Yu6J6USgTux6VgDJNnqwTI","esAr_byuMPhBzVc","s-0NuKm9F9gtoCk","9Q2qfQR903fLDKVZ","4XHnPhpN81M","M8MZtfOvJQ","LC6RcUR2wgzhZ-lJRZr06ngZ","064419OML5BG1Es","_R6JGBhLtFnvLqQ","whuVd1Aq1i3vOecEfIA","KsJWj4LaPql-yB3v5ggKdA","kSDUaGt6xSXm","SkrKFytE7GWq","k3jiMiFTq1uBHIh5NNf4vAsE","MuABua26Ac4mmTzKwUEJN64","etF-u47uXM4D4GbSpGdoD6W0","mfdS26rVcq8_1VY","GhzyGXdj7xCdBqQDMeKbnFo","EBncL0t31UD6N6MLMNe9ow","WbUc3Yr3ef1yhQX1","u3iKJ1tO3CCtOphlSNc","aM1Uh4KBfs4n00yI6l9UaZs","4z6ZXlFf8w","SH6zM2Av6BXZKMp1T-jvp1R-GrY-","cxK9Y2c","xaBpr630EQ","49","fromCharCode","k8Mp27yHIvhTwnHY9ypfcZu78AnK","2XrPMjJtmw","dfpI9Yv-","\\s","xFnYJxk","cFD1dnopmiiBQfgm","Promise","DNkpq7DUd9cV","4gGBT0BX2nq4HJQwHLfigCY8L_Bwats","b2z8JjU","gEX2NywznEuAIQ","-ZRJ_uPwRJB9","cL084ffDWt0dv2SUlnNDDazrziOl_x4bNg","QNJx6OuvCZsU8XeFxnA1Vfbjkzal9Q4NLMyzpcdOm6uR4cA","fm2DIhI_mFKwI5dPTt6pgC1QTq0DSJnstTksYwg","86","imHbHAxVrmHdVM02","i4MI08zAarI-mUO9qAMcPoPa5VA","0CicD2MV2EqQ","Hc9Ehq-HF7VG-1_m1w","Ee5Du4bmLA","hEjvbCgiiw","Yth-0KD1","HyybDU9al3LiCJg","_spdhpPED-Mu41qSzhlV","fnqEPBI51h6jJqgIWMKhvSNIUooZWJKxwg","TE34BDxDsWyMEpNiMvrWkgQwbroTe_uVx19sX1KneEozd3bs6cJlvqM1W9dfiyf4vRNNmetkq2DBrxGoZz0Wha0CI9nS8Y3EhQePGsgtmsL0U_F0H8AjFas","24","HpJQqru-Q8tK","t9dpvuqIB54H0HuB","41","duRLoJ8","hifuVDcxzRjFR_glSKw","--Nd0qz_OrE0wnirjQ","Math","Y-YPrr68AfhF6ivAwmlM","-TmsSmYW9SrhSd0xCca70kpEadk","a8pqi-y0K9gn5FI","111","iFXdEh1shHaKI6tR","pfgataKgENUl","55EAi8fNP_J3zDP86gwPNdbd3EnNhxBsX4aK","dLYv6dqlWaRW_EaPv30","o5MEztSZS6F6jQ","0XnWKhEuznO_Obk","rzWZQjgF1iDTXw","9JAMzMbmauAOl2Oh","97","console","XWfNFSBink-t","-E7AGBYA","Pz-Fa1s4tQLUWN0MU5zfnhYsNPo","Error","mXPwLg56qUOSVOMgZrT-","G6AH6fiwSw","KdEay9aWbqoYgBv5qRAIE8-f-Bw","xshorPn8HpMP8ArA134KX-Xq2W4","([0-9]{1,3}(\\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})","EoIkrO_wN8l5pALC3hs7Ke2j","FwmJXmtE7jShHNlSKauM","FQatdS5d1E7gEY5uKqc","VgjaByJZtWal","qagq4_D4KqlTrCnVkiMsDA","-S6BBXQM","4","gIct2aTEPvlIjVDg6jIUQZ-q9z3AtzFX",...```
A 25kb array of encrypted constants (functions, offsets, keys, strings)

The string I located in the pcap appeared in some initialization code for a strange event: (snipped for brevity):

(function(e) {
  e.initCustomEvent("XyunHUgug", false, false, ["AyitXI1zAQAAjKszx1DbNZWmK1wxfkqy8tzDqhtPmqrJz6......vAAAAAA==", "h-dcoMP.......gvn5C8FBHxJtIGwS9U", [],
    [1099101272, 1331287587, 1662884978, ...], "OE9HjV.....MlH3DRmpaM", "OE9H....", [
      [/(?:)/, /^((?=.*\.netgear\.com$)(?!.*stg))/i, .... , /^((?=\/oauth\/resetpassword\/))/i, /^(

I wrote a tampermonkey script and triggered a breakpoint when that event was invoked:

// ==UserScript==
// @name     Meural Debugging
// @namespace
// @version    0.1
// @author     Martin Amps
// @match*
// @run-at     document-start
// @grant    none
// ==/UserScript==

(function() {
  var customEvents = [];
  window.addEventListener = (function(origAddEL) {
    return function() {
      const args = arguments;
      if (customEvents.includes(args[0])) {
        args[1] = function() {
          return args[1].apply(this, arguments);

      return origAddEL.apply(this, args);

  document.createEvent = (function(origCE) {
    return function() {
      const e = origCE.apply(this, arguments);

      if (arguments[0] === 'CustomEvent') {
        const origICE = e.initCustomEvent;
        e.initCustomEvent = function() {
          origICE.apply(this, arguments);

      return e;

To my surprise I stumbled upon what looked like a VM …. gating their login page? …..

function iB(iC) {
    var iD, iE;
    for (;;) {
        if (et !== d) {
            iE = et;
            et = d;
            return iE
        iD = iC.b();
        if (iC.c.length === 0) {
        } else {
            bo(dA[iD], iC)

After some stepping through with the debugger, I was able to locate the decryption function:

function(dX) {
    var dY = dX.d2();
    var dZ = I[dY];
    if (typeof dZ !== "undefined") {
      dX.I[dX.I.length - 1] = dZ;
    var ea = dX.I.u();
    var eb = H[dY];
    var ec = M(eb);
    var ed = M(ea);
    var ee = ec[0] + ed[0] & 255;
    var ef = "";
    for (var eg = 1; eg < ec.length; ++eg) {
      ef += u(ed[eg] ^ ec[eg] ^ ee)
    I[dY] = ef;

I then set another breakpoint in the decryption function, traversed up the callstack, and after a little digging managed to (finally!) locate our magic header key:

  "uuidTokenKey": "f",
  "integrityKey": "b",
  "bundleSeedKey": "c",
  "bundleIdKey": "d",
  "firmwareKey": "z",
  "payloadKey": "a",
  "bundleSeed": "Ax17W41zAQAAVAahkfTXiodilKwVDdavZ_5JmtO0QjrhV7y732tdhWYCCkrGAVIc7lKucmGVwH8AAEB3AAAAAA==",
  "bundleId": "o_0",
  "firmware": "p",
  "instrumentationStateKey": "cUsMIKCT",
  "headerChunkSize": 7900,
  "headerNamePrefix": "X-Lz0Ckp04-",
  "xhrStateKey": "SrhtHHFTF",
  "uuidToken": "AyitXI1zAQAAjKszx1DbNZWmK1wxfkqy8tzDqhtPmqrJz6dX4pJLKrSMldILATTKpRCucmGVwH8AAOfvAAAAAA==",
  "base64Alphabet": "h-dcoMPy7lsRXZfYjWDT0aOQA3k=LNb6rEum4z21_ipKeVqgvn5C8FBHxJtIGwS9U",
  "shuffleKey": [],
  "encryptionKey": [1099101272, 1331287587, 1662884978, 2046360320, 1716290900, 326586344, 1052250470, 2006171371],

While I was there, I used the Chrome debugger to modify the source to generate a decrypted array!

  '0ZDgbFyyUEAtlQ1R_zb-DNY69w': 'Client::afterReady',
  'arrpMDqUMh0WijwajxjuGrAg41U': 'setLocalDescription',
  'Zi8I6vdrxs24SMHMOP8B8yjL': ':afterReady:start',
  '2xQEkbNagpuTaPnq': ':afterReady',
  'LMi4KUP3IRgD': 'suffixes',
  'bPijO1H1OhtO50o0y3TGT8QdvleizZM': 'Default Browser Helper',
  '2F8P0cwy0f2mH7LaDvEY7iTXIt0Lci3KSRTpHqPMA-JnDkI': 'Widevine Content Decryption Module',
  '96n7difdAjgf6y0zrBb8CA': 'Hel$&?6%){mZ+#@',
  '6AdGvIA-wQ': 'canvas',
  'FLDRRwGxYkZ5khtenCCD': 'XMLHttpRequest',
  'jqvbDTPkElIR9xxb': 'originalXhr',

The shiny object above revealed how I could construct the initial set of headers, for example it clearly articulates the mapping such that uuidTokenKey = f means that x-lz0ckp04-f should be uuidToken (or AyitXI1zAQAAjKszx1DbNZWmK1wxfkqy8tzDqhtPmqrJz6dX4pJLKrSMldILATTKpRCucmGVwH8AAOfvAAAAAA==).

| header       | mapping    | value                                                                                    |
| x-lz0ckp04-a | payload    | ???                                                                                      |
| x-lz0ckp04-b | integrity  | ???                                                                                      |
| x-lz0ckp04-c | bundleSeed | Ax17W41zAQAAVAahkfTXiodilKwVDdavZ_5JmtO0QjrhV7y732tdhWYCCkrGAVIc7lKucmGVwH8AAEB3AAAAAA== |
| x-lz0ckp04-d | bundleId   | o_O                                                                                      |
| x-lz0ckp04-f | uuidToken  | AyitXI1zAQAAjKszx1DbNZWmK1wxfkqy8tzDqhtPmqrJz6dX4pJLKrSMldILATTKpRCucmGVwH8AAOfvAAAAAA== |
| x-lz0ckp04-z | firmware   | p                                                                                        |

Getting the last two items appeared to be a loooot of effort for not much return. It also seemed quite brittle; small changes in how they encrypt the payload and generate the signature could require a large investment to maintain. I decided to change strategies and blackbox the script into working in a sandboxed environment so I could exfiltrate the headers through a (theoretically) reasonably stable API..

Working with the black box

While it would be easier to do this with a headless browser like puppeteer, I opted to optimize for lower latency, reduced dependencies, and most importantly, fun. To start, I downloaded the ngr_common.js file and loaded it in an index.html locally. Then I started deobfuscating the minified code adding logging and slowly replacing browser clases such as XMLHttpRequest with my own stubs until it would run as a standalone script. It was quite a game of whackamole and often required me to compare the executions in the browser with a version invoked via node --inspect ngr_common.js and infer (based on the constants decrypted) what it was doing, so I could stub out those components in the browser.

< decrypted QBVF28A18dbABq6sPg afterReadyCb
< decrypted EJ3ua2iBWmV1ryYxjFC0PuwRzQ Transmission::init
< decrypted sfikNQH1 fetch
< decrypted Kea1BXKfT3FLnH9y_Ea5Wg onSubmitWrapper
< decrypted eI7pOzqXOg submit
exception in ngr_common.js:5492
 5490         }
>5492         ft.I.p(h(fw, fx, fv))
 5493     }, function(fy) {
 5494         fy.m = 0

Eventually, I got it working! There were many red herrings on the way… I ended up mocking out all kinds of webrtc and browser apis such as RTCPeerConnection, LocalStorage, naviator.getMediaDevices, and navigator.plugins. Once it was working, I was able to remove those parts — their code gracefully handles the absence of those APIs.

working token

I then combined the method I used to generate a valid xtoken with this new method of exfiltrating headers to see if I could execute a successful authentication request!

const JSEncrypt = require('node-jsencrypt');
const fetch = require('node-fetch');

const PUBLIC_KEY = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA6oLssFCWGau5e4HtxNfeb0Az8phTtcxiAUb+Whtb6asLUMiHKDwbXKUf6GmsUKkJceidR4n2x17SxmEl8us9hda3X9a53kzOEQLgb8G5sKE0jIc6oCXurvQEP3F9t4lxWRjesDU9cbH8eZmpsQYmXgAr+lFj5xDmCkbS7XF0ejfxOqF9VcwUWzCCj+WTiFYZt+C7Ujz1YNubWAqWLHCay8SwdKtF5/BUYiztGrwUyXULhCpHd4blL8zU7vPeAMCqvDuV/a+J5ZQTJwO41iv3X+n7l7inme+84jfbc+TC4pZTG2CU8EHm+rpkBxng2oiUQn/ok6dUPjVkNCfdAEpbZQIDAQAB';

const NGR_COMMON_URL = '';
let payload = {"emailReset":"", "serialNumber":"", "sessionID":"", "devtypeid":"", "xtoken":"", "tokenType":"", "userAgent": {}};

// This wrapper script will be prepended to the ngr_common.js code before we execute it to trick it into thinking it's running in a browser, so it will calculate the header values for us
const wrapper =
// The fake implementation of XMLHttpRequest to allow us to capture the setRequestHeader invocations, ultimately exfiltrating the x-lz0ckp04-* headers.
class XMLHttpRequest {
    constructor() {
        this.headers = [];

    open() { }
    send() { }

    setRequestHeader(k, v) {
        this.headers[k] = v;

class CustomEvent {
    initCustomEvent() {
        this.type = arguments[0];
        this.detail = arguments[3];

function dispatchEvent(event) {
    const listeners = callbacks && callbacks[event.type] || [];
    for (var i = 0; i < listeners.length; i++) {
        listeners[i].call(this, event);

    return true;

const callbacks = {};
const document = {
    addEventListener() { },

    // Widevine uses a cool trick where it creates an <a> tag to which it assigns a href for the browser to helpfully parse that url so it can pull out protocol, hostname, pathname, etc for it to do comparisons against internally.
    createElement() {
        if (arguments[0] === 'a') {
            myAnchor = new Object();
            myAnchor.hostname = '';
   = '?redirectUrl=';
            myAnchor.protocol = 'https:';
            myAnchor.pathname = '/mfa/auth';
            return myAnchor;

    createEvent() {
        return new CustomEvent();

const window = {
    XMLHttpRequest: XMLHttpRequest,

    removeEventListener() {
        delete callbacks[arguments[0]];

    addEventListener() {
        callbacks[arguments[0]] = [arguments[1]];

    HTMLFormElement: function () { },
    Event: function () { },
    screen: null,
    Array, parseInt, Object, Function, Date, Math, undefined,
    isFinite, unescape, Infinity, document, String, encodeURIComponent

for (const key in window) {
    global[key] = window[key];

const getHeaders = () => {
    const xhr = new window.XMLHttpRequest();'POST', '');
    xhr.send('<<payload>>'); // we will string replace <<payload>> dynamically
    return xhr.headers;

function getEncryptedPassword(email, password) {
  const encrypt = new JSEncrypt();
  const epochTime = Math.round((new Date()).getTime() / 1000);
  return encrypt.encrypt(`${email} ######-----##### ${password} ######-----##### ${epochTime}`;);

// Download the ngr_common.js file
fetch(NGR_COMMON_URL, {method: 'GET'})
    .then(res => res.text())
    .then(script => {

      // Add the encrypted credentials
      payload.xtoken = getEncryptedPassword(process.env.MEURAL_EMAIL, process.env.MEURAL_PW);
      // Substitute the xtoken into the wrapped script
      const wrappedScript = wrapper + script;
      const finalScript = wrappedScript.replace('<<payload>>', JSON.stringify(payload)) + '; getHeaders();';
      // Execute the code and retrieve the result
      const headers = eval(finalScript);

      // Now we can invoke the auth endpoint with the correctly signed headers
      fetch('', {
        method: 'POST',
        body: JSON.stringify(payload),
        headers: {
          'Content-Type': 'application/json',
          'Origin': '',
          'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36',
          'Cookie': ';',
      }).then(res => res.json())
        .then(json => console.dir(json));


Sweet, we finally got a token! Originally I had intended this to be a weekend post to relaunch this blog but it required a couple of hours a night for several more days.

Disclaimer: As a general rule I strongly advise against executing arbitrary javascript from a 3rd party. In my case, I am executing this in a sandboxed environment and the worst they could do is exfiltrate my meural credentials which… they already have.

Putting the pieces together

Finally, with the hard part out of the way I can turn attention back to functionality, and using Twilio to stream MMS to our picture frame! Disclaimer: I am a Twilio employee and you’re welcome to use this link to receive an extra $10 in credit after upgrading your account.

When uploading an image via the Meural portal, it makes the following request:

meural imageupload request

So, given I’ve already figured out how to get token, all that’s left is to convert that request to Javascript and attach it to our phone number within the Twilio Console!

After purchasing a number ($1/month), I pointed it to a Twilio Function, which is essentially an AWS Lambda wrapper and shared the number with my friends where hilarity ensued.

Twilio Functions Code

Twilio Number Configuration

I hope you learned something from this post, I had a lot of fun writing it. I’d love to hear any feedback - feel free to e-mail or tweet me :)

The final artifact:

const fs = require('fs');
const url = require('url');
const path = require('path');
const request = require('request');

let tokenCache;

exports.handler = async (context, event, callback) => {
    const twiml = new Twilio.twiml.MessagingResponse();
    let broken = '';

    if (!event.NumMedia || parseInt(event.NumMedia) === 0) {
        twiml.message('Please send an image!');
        return callback(null, twiml);

    for (let i = 0; i < event.NumMedia; i++) {
        const mediaUrl = event[`MediaUrl${i}`];
        const mediaMime = event[`MediaContentType${i}`];
        const ext = mediaMime.split('/')[1];
        const mediaSid = path.basename(url.parse(mediaUrl).pathname);

        const target = `/tmp/${mediaSid}.${ext}`;
        if (!fs.existsSync(target)) {
            await downloadImage(mediaUrl, target);

        let MEURAL_TOKEN = tokenCache;
        if (!MEURAL_TOKEN) {
            const tokenResult = await makeRequest('GET', null, context.MEURAL_TOKEN_SANDBOX);
            MEURAL_TOKEN = tokenResult.token;
            // As long as our container is alive, we'll have this token
            tokenCache = MEURAL_TOKEN;

        const customOptions = {
            formData: {
                image: fs.createReadStream(target),

        const result = await makeRequest('POST', MEURAL_TOKEN, '', customOptions);
        if (!result || ! {
            // prepend a broken message to the SMS to my cell so I can investigate
            broken = '(broken!)';
        } else {
            await Promise.all([
                makeRequest('POST', MEURAL_TOKEN, `${context.MEURAL_GALLERY_ID}/items/${}`),
                makeRequest('POST', MEURAL_TOKEN, `${context.MEURAL_DEVICE_ID}/items/${}`),

    if (event.From != context.MAMPS_CELL) {
        const mms = twiml.message({
            to: context.MAMPS_CELL

        mms.body(`${broken} Meural got a new photo from ${event.From}`);['MediaUrl0']);

    twiml.message('Thank you!');
    callback(null, twiml);

async function makeRequest(method, token, url, customOptions) {
    return new Promise((resolve, reject) => {
        const options = Object.assign({
            headers: {}
        }, customOptions);

        if (token) {
            options.headers['Authorization'] = `Token ${token}`;

        request(options, (err, httpRes, body) => {
            if (err) {

            let parsed;
            try {
                parsed = JSON.parse(body);
            } catch (e) {
                return reject(`failed to parse json: ${e.message}`);

            return resolve(;

async function downloadImage(source, target) {
    const writer = fs.createWriteStream(target)
    const response = await axios({
        url: source,
        method: 'GET',
        responseType: 'stream'
    return new Promise((resolve, reject) => {
        writer.on('finish', resolve);
        writer.on('error', reject);