PsExec’ing the right way and why zero trust is mandatory
2021 was the year I met two incredible hackers, Michael and Reino with whom I had the opportunity to work with during my first ever SenseCon.
The Sensecon is an internal conference that lasts 3 days during which we meet people, share knowledge and have fun. There is also a day long hackathon during which we work on hacking subjects we are interested in.
For that hackathon, we wanted to dig into PsExec.exe in order to see if it is possible to communicate with it via a python script and thus not depend anymore on a windows system. Spoiler alert, we were able to! But for some reasons, the project died in a private repo.
2021 was the year I met two incredible hackers, Michael and Reino with whom I had the opportunity to work with during my first ever SenseCon.
The Sensecon is an internal conference that lasts 3 days during which we meet people, share knowledge and have fun. There is also a day long hackathon during which we work on hacking subjects we are interested in.
For that hackathon, we wanted to dig into PsExec.exe in order to see if it is possible to communicate with it via a python script and thus not depend anymore on a windows system. Spoiler alert, we were able to! But for some reasons, the project died in a private repo.
Until a few weeks ago where I really needed such a tool to bypass a specific EDR.
Seeing that it worked quite well, I thought of finishing the project and publishing it alongside this blog post to explain how we achieved that. In this blog post, we’ll have a glimpse at how PsExec.exe works, we’ll write a python script that allows us to act as a legitimate PsExec.exe client and finally, we’ll see why zero trust is a core requirement of cybersecurity.
1/ How does PsExec.exe work
Lots of people have already explained it, but since we are going to mimic it, I thought it would be interesting to explain it one more time. For those unaware, PsExec.exe is one binary among an entire toolkit called Sysinternals which was first released in 1996 by Mark Russinovich. As of today, the latest version of PsExec.exe is version 2.43 which can be downloaded here.
Most of the time you will use PsExec in two ways:
- To gain local system privileges:
PsExec.exe -s -i cmd
- To execute commands remotely as a domain user:
PsExec.exe \\dc.whiteflag.local -u WHITEFLAG\Administrateur -p Defte@WF cmd
To achieve remote command execution, PsExec.exe relies on 4 steps:
- Extracting and uploading PsExeSVC.exe
When you run PsExec.exe, the first thing it does is extract PsExeSVC.exe, the server side component, which is embedded in PsExec.exe. See the following binwalk output:
This binary is then uploaded to the ADMIN$ share (pointing to C:\Windows) as PsExeSVC.exe:
2. Launching the PsExeSVC service
Then PsExec.exe, the client, connects remotely to the SVCCTL RPC endpoint which is used to manage services and calls 4 interfaces:
- OpenSCManagerW to connect to the Service RPC endpoint ;
- OpenServiceW to create a new service ;
- StartServiceW to start the newly created service ;
- QueryServiceStatus to make sure that the service is actually running.
And indeed we can see that a new service, PSEXESVC is running:
3. Sending init packet
When PsExeSVC starts, it first creates a named pipe called psexecsvc which we will call the initialisation named pipe:
Right after this pipe is set up, a transceive operation occurs between the client (PsExec.exe) and the server (PsExeSVC.exe) where they both send their version and receive the version of the other part:
Looking at the content of these packets, we will see that they both send 4 bytes of data containing a numeric value stored in hexadecimal (little endian):
Which translated, tells us this is PsExec.exe and PsExeSVC.exe version 1.9:
Little endia = BE000000
Big endian = 0000000BE = 190 = version 1.9
Why am I not using the latest version? This is because once both components have sent their respective versions, the client will send 19032 to 19040 bytes of data (depending of the version of PsExec). Thing is, since PsExec 2.20, that data, and all later communications, are encrypted. So here is what you would see if you are monitoring PsExec.exe v2.43 communications via Wireshark:
And here is the exact same packet with version 1.90:
Looking at this one, we can already understand why newer versions of PsExec implement encryption; because the client was sending clear text credentials over the network (we will see why later) which are prone to man in the middle attacks.
We can also see that a program is specified, cmd.exe, as well as the computer name from where PsExec.exe was executed (a VM of mine whose hostname is COMMANDO). Notice that all this clear text information is surrounded by null bytes. That is because that data is not just random data, but is a structure whose contours can be seen:
# Size of the packet to be read by PsExeSVC (19032)
584a0000
# Little endian hexadecimal for 5800 (don't know what it's used for yet)
a8160000
# String (C.O.M.M.A.N.D.O)
43004f004d004d0041004e0044004f00 [ LOTS OF ZEROS ]
# String (C.M.D)
63006d006400 [ LOTS OF ZEROS ]
# Some bytes ??
00000101000000000000000000000000ffffffff0100
# String (W.H.I.T.E.F.L.A.G.\.A.d.m.i.n.i.s.t.r.a.t.e.u.r)
5700480049005400450046004c00410047005c00410064006d0069006e00690073007400720061007400650075007200 [ LOTS OF ZEROS ]
# String D.e.f.t.e.@.W.F
44006500660074006500400057004600 [ LAST ZEROS ]
Right after that structure is received by PsExeSVC.exe, three new named pipes are created:
- PSEXECSVC-X-Y-stdin used to send commands we want to run remotely ;
- PSEXECSVC-X-Y-stdout used to retrieve the output of the command ;
- PSEXECSVC-X-Y-stderr used to retrieve the errors.
With:
- X being a string ;
- Y being a numeric value.
Listing named pipes on the remote target with PowerShell :
Get-ChildItem \\.\\pipe\
Shows us the following ones:
What about that 5800 numeric value ? Well, looking at the task manager on the computer where PsExec.exe was launched we will see the following:
Which implies that this value (Y in the previous pattern) is actually the PID of PsExec.exe.
Now we understand that the psexecsvc named pipe is here to receive information that will be used to create the other three named pipes. Schematically, we can say that PsExec.exe creates named pipes this way:
Last thing we need to know is how these PsExec.exe options are sent:
Since I didn’t want to decompile the binary, which is not really TOS compliant, I just thought of flipping random options and see the differences in the Wireshark outputs. And so I did, until I realised that there is a 32 byte buffer that only contained 0’s and 1’s depending of the options used with PsExec.exe.
At that point I was now able to define the structure of the data passed to the PsExeSVC named pipe which is the following:
class PsExecInit(Structure):
structure = (
('PacketSize', '<I'), # 19032 (size of the init packet)
('PID', '<I'), # PID of the PsExeSVC.py script
('Computer', '520s'), # Hostname of the computer where PsExeSVC.py is run
('Command', '520s'), # Remote binary to call
('Arguments', '520s'), # Arguments
('OthersOptions', '16385s'), # Some space mostly used to copy files
('ElevateToSystem', '1s'), # Whether we elevate to system or not
('Interactif', '1s'), # Whether launch interactive session (no it won't let you type command otherwise)
('LogonUser', '1s'), # Logs the user in remotely (which enables Windows SSO)
('RestrictedToken', '1s'), # Do we want a restricted priv token ? (nope LOL)
('EnableAllPrivs', '1s'), # Do we able all privileges (HELL YEAH!!)
('OthersFlags', '16s'), # Others options we don't really need
('Username', '520s=""'), # DOMAIN\Username
('Password', '520s=""'), # Password
('Padding', '18s=""') # Padding
As you can see quite a lot of data is stored in that structure, with the most important ones being the following flags:
- LogonUser which allows us to have a shell as the authenticated user ;
- EnabledAllPrivs which allows us to have a full token privilege ;
- ElevateToSystem which allows us to have a NT AUTHORITY\System remote shell.
[...]