Messing with LD_LIBRARY_PATH for fun and profit

A NAS full of CGI binaries and SSH root access. What could go wrong?

Messing with LD_LIBRARY_PATH for fun and profit

Let me give you a context first.

I have a NAS. It's a simple one, a 4-bay Asustor AS3104T. It works great and currently does Plex hardware encoding, store my backups and runs a few extra Docker containers for stuff I need. It's great! It has also some nice stats on it, like usage and temperatures.

Who thought 2GB would be more than enough for a NAS? (tip: it's not)

On the other side, I run Home Assistant, where I've been putting some information about the temperatures of my home servers and its drives. You know, stuff normal people want to automate, right? Like turning on fans, getting alerts if something is weird, etc. Normal people do this, right? Right?

Yes, they are running a bit hot, I know

Well, I wanted to get the NAS information on the HA. Simple right? Yeah.. not really. You see, the NAS does not have a simple way of obtaining this data. My standard approach is to check the /sys/devices/virtual/thermal tree, as it usually contains all the data I need

# ls -al /sys/devices/virtual/thermal/
total 0
drwxr-xr-x    7 root     root             0 Nov  7 17:16 ./
drwxr-xr-x   19 root     root             0 Oct 26 00:00 ../
drwxr-xr-x    3 root     root             0 Nov  7 17:16 cooling_device0/
drwxr-xr-x    3 root     root             0 Nov  7 17:16 cooling_device1/
drwxr-xr-x    3 root     root             0 Nov  7 17:16 cooling_device2/
drwxr-xr-x    3 root     root             0 Nov  7 17:16 cooling_device3/
drwxr-xr-x    3 root     root             0 Nov  7 17:16 thermal_zone0/

# cat /sys/devices/virtual/thermal/thermal_zone0/temp

# cat /sys/devices/virtual/thermal/cooling_device0/cur_state

# cat /sys/devices/virtual/thermal/cooling_device1/cur_state

# cat /sys/devices/virtual/thermal/cooling_device2/cur_state

# cat /sys/devices/virtual/thermal/cooling_device3/cur_state

Yeah, nothing. But the web interface must get it from somewhere, right? Turns it it does - from an API endpoint:

Fancy, but .cgi? Ouch.

So, what is this sysinfo.cgi? A quick disk search revealed it it's an actual binary on the system:

# file /volume0/usr/builtin/webman/portal/apis/information/sysinfo.cgi
/volume0/usr/builtin/webman/portal/apis/information/sysinfo.cgi: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/, for GNU/Linux 2.6.32, BuildID[sha1]=4b17db272e002be02408d9523327810e00a54b47, stripped

So, it's a binary. But how does it work? I mean, how to I call it? Can I call it directly?

# /volume0/usr/builtin/webman/portal/apis/information/sysinfo.cgi
Content-type: text/plain; charset=utf-8

{ "success": false, "error_code": 5000 }

Ok, fair enough, calling it straight away doesn't help. I need to understand how the HTTP daemon is calling it, so I decided to replace it with a shell script that would later call the original one. The volume where this file is stored is writeable, so this was pretty easy.

# cd /volume0/usr/builtin/webman/portal/apis/information
# mv sysinfo.cgi sysinfo.cgi.old

# cat sysinfo.cgi

export > /tmp/sysinfo_dump.log
echo "--" >> /tmp/sysinfo_dump.log
echo "$@" >> /tmp/sysinfo_dump.log

/volume0/usr/builtin/webman/portal/apis/information/sysinfo.cgi.old $@

The dump file gave me all the information I needed, basically:

export DOCUMENT_ROOT='/usr/webman/'
export HTTP_ACCEPT='*/*'
export HTTP_ACCEPT_ENCODING='gzip, deflate'
export HTTP_ACCEPT_LANGUAGE='en,de;q=0.9,de-DE;q=0.8,en-US;q=0.7'
export HTTP_CACHE_CONTROL='no-cache'
export HTTP_CONNECTION='keep-alive'
export HTTP_COOKIE='(...)'
export HTTP_DNT='1'
export HTTP_HOST='xxx.lan:8000'
export HTTP_ORIGIN='http://xxx.lan:8000'
export HTTP_PRAGMA='no-cache'
export HTTP_REFERER='http://xxx.lan:8000/portal/'
export HTTP_USER_AGENT='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.183 Safari/537.36'
export PWD='/volume0/usr/builtin/webman/portal/apis/information'
export QUERY_STRING='sid=YgqnX.nhdkhyQoty&act=sys&_dc=1604785963061'
export REDIRECT_STATUS='200'
export REMOTE_ADDR='172.16.x.y'
export REMOTE_PORT='55402'
export REQUEST_URI='/portal/apis/information/sysinfo.cgi?sid=YgqnX.nhdkhyQoty&act=sys&_dc=1604785963061'
export SCRIPT_FILENAME='/usr/webman/portal/apis/information/sysinfo.cgi'
export SCRIPT_NAME='/portal/apis/information/sysinfo.cgi'
export SERVER_ADDR='172.16.x.x'
export SERVER_NAME='xxx.lan'
export SERVER_PORT='8000'
export SERVER_SOFTWARE='lighttpd/1.4.29-devel-187'

So lighttpd is basically writing everything as environment variables and calling the binary. A bunch of trial and error later, I managed to figure out it only needs the QUERY_STRING variable - everything else can be removed. The query string is made of 3 arguments:

  • sid: the token - we receive that after logging in
  • act: not sure, but I guess it's what are we trying to get the info from
  • _dc: current timestamp, probably for to avoid caching the response on lighttpd

The first thing I tested is if the token is checked - and it is. It took me a while to figure out against what, as it would need some kind of table to keep them. Logging out of the web interface invalidates the token, so it must be stored somewhere. Turns out it's in the disk, at /var/run/login.log (weird name, I know):

# cat /var/run/login.log
YgqnX.nhdkhyQoty        00129B47        00128D06        --------        000003E7        172.16.x.y    admin

This is a tab-spaced file containg the token, two numbers, dashes, another number, the source IP for this session and the username. The dashes will include a "D" if you logged out - probably meaning disabled. In theory, this would be enough to make the call, as we could get this using grep and cut:

# TOKEN=$(cat /var/run/login.log | grep -Pv '\tD' | cut -d$'\t' -f1)
# QUERY_STRING="sid=$TOKEN&act=sys&_dc=1604780579403" ./sysinfo.cgi
Content-type: text/plain; charset=utf-8

{ "success": true,"model":"AS3104T", "cpu":"Intel® Celeron™ CPU @ 1.60GHz", "mem":"2048", "serial":"XXX", "os":"3.5.1.R8C1", "timezone":"(GMT-03:00) Brasilia", "time":"1604786559", "year":"2020", "month":"11", "day":"7", "hour":"20", "minute":"2", "DateFormat":"1", "TimeFormat":"1", "uptime":"1219624", "cputemp":"56", "bios":"2.23", "bootloader":"", "fan_speed": ["528"], "systemp":"37","aid":""}

This, however, wouldn't work if there was no active token. I know for a fact that the web interface will log me out after a few days (probably what those numbers mean), so this wouldn't work for an automated process. This would require a logged user, which I'm not willing to have.

So, I needed a different approach. How about checking the binary, what it does and so on? How about running a simple strace on it?

Tracing the execution showed a bunch of interesting things. The first thing that caught my eye was a read against a hardware device:

# grep temp sysinfo.log | grep -Ev "ENOENT|model"
access("/sys/devices/platform/coretemp.0/hwmon/hwmon1/temp2_input", R_OK) = 0

# cat /sys/devices/platform/coretemp.0/hwmon/hwmon1/temp2_input
One down, too many more to go!

So this is how it reads the CPU temperature! Nice! But unfortunately there are no other devices around that tree and I didn't manage to get any useful information from any other (virtual?) device around there.

Ok, let's take a different approach. How about cracking the session? The NAS is x86-x64, so we could modify the binary to not check the token and just return the information we want. Or maybe we could just modify which file it reads to something we can control: this would be way easier and faster. The tracing did indeed show it was reading the login.log file:

stat("/var/run/login.log", {st_mode=S_IFREG|0600, st_size=288, ...}) = 0
open("/var/run/login.log", O_RDONLY)    = 4
read(4, "m.KmXyIBGkgkYZYp\t00127814\t001275"..., 288) = 288

Running strings on the sysinfo.cgi binary didn't yield anything login related though, so it must get this information from somewhere. Maybe some library?

# grep "open(" sysinfo.log | grep "lib/" | grep -v ENOENT
open("/usr/builtin/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/", O_RDONLY|O_CLOEXEC) = 3

Ok, so that shows some very promising libraries! libnasman and libcgi seem like some custom ones, so let's check them!

# strings /usr/lib/ | grep login.log

# strings /usr/lib/ | grep login.log

Nice. Now we just have to change them without breaking the whole OS and being update-proof. Why take the easy path when we can make it harder, right?

Null-terminated strings!

So... let's replace it. Let's change it to something we control. How about /tmp/fake.login?


Null-terminated strings can have as many 0x00 at the end. As long as we don't overflow that string, everything should be just fine.

Doing that to libnasman had no effect, still failing the execution, but replacing on libcgi did the trick:

# QUERY_STRING="sid=foobar&act=sys&_dc=999" LD_LIBRARY_PATH=. strace /volume0/usr/builtin/webman/portal/apis/information/./sysinfo.cgi 2>&1 | grep fake.login
open("/var/lock/fake.login.lck", O_RDWR|O_CREAT|O_NOATIME, 0666) = 3
stat("/tmp/fake.login", {st_mode=S_IFREG|0600, st_size=72, ...}) = 0
open("/tmp/fake.login", O_RDONLY)       = 4
open("/tmp/fake.login.tmp", O_WRONLY|O_CREAT|O_TRUNC, 0600) = 4
rename("/tmp/fake.login.tmp", "/tmp/fake.login") = 0
Now we're talking!

You see, all I had to do was to copy the library to the current folder, modify it and set the LD_LIBRARY_PATH, which will tell the OS where it should look for dynamic libraries. Since libcgi is loaded dynamically, this allows us to have a modified copy of it to override the default behaviour. By doing so, we stick with the original binary, but everything elese that needs this libcgi for checking the session will be compromised if I a custom library path for it. Cool!

So all that's left is to write the fake.login file. We didn't modify the binary, so it still needs to check for a session. Nothing that a simple script won't fix:

# cat
export QUERY_STRING='sid=-fake-cgi-token-&act=sys&_dc=1604780579403'

echo "-fake-cgi-token-  001294C1        00128D06        --------        000003E7        172.16.x.y    admin" > /tmp/fake.login

/volume0/usr/builtin/webman/portal/apis/information/./sysinfo.cgi | grep "{"

# ./
{ "success": true,"model":"AS3104T", "cpu":"Intel® Celeron™ CPU @ 1.60GHz", "mem":"2048", "serial":"XXX", "os":"3.5.1.R8C1", "timezone":"(GMT-03:00) Brasilia", "time":"1604787981", "year":"2020", "month":"11", "day":"7", "hour":"20", "minute":"26", "DateFormat":"1", "TimeFormat":"1", "uptime":"1221046", "cputemp":"56", "bios":"2.23", "bootloader":"", "fan_speed": ["529"], "systemp":"37","aid":""}
A grep at the end I only want the JSON :)

That's it. Now all I have to do is to use my trusty jq to parse that JSON and get whatever I want/need from it. In my case I wanted to know the CPU and system temperatures, plus the fan speed:

# ./ | jq -r ".cputemp"

# ./ | jq -r ".systemp"

# ./ | jq -r ".fan_speed[0]"

Update: or so I thought.

A few minutes I published this post, I started having some errors. Turns out those binaries also check the expiration date on the sessions. And again by trial-and-error I managed to figure out that the second value (the first number) is the expiration date. Refreshing the web page would change that value in the real file, increasing it by the same number of seconds I waited before pressing F5. The second number (third field) seems to be the session start date, as it is smaller than the previous. And this information is shown

Replacing 001294C1 with 0FFFFFFF did the trick and the script started working again. Just for the sake of it, I've also replace the 00128D06 with 00000000. Honestly, the 00128D06 is a mystery for me still - what is based on? Seconds after something? But what? Subtracting 00128D06 from 001294C1 gave about 7753, which, once added to the original login time, shows the exact time I did the last API call. Interesting.

For now, since the script is working again, I won't bother figuring it out. Let's see for how long it works! Ah, and this is the final result:

It works \o\