Breaking out from stripped tokens using process injection

Intro

What I am going to showcase here isn't anything new, but it lays out the groundwork for what I am going to publish next, soon-ish.
This article will help you learn about:

  • attacking Windows services running under dedicated service accounts (such as NETWORK SERVICE and LOCAL SERVICE), using PostgreSQL 17 as an example,
  • process security tokens,
  • implementing DLLs,
  • process injection,
  • writing simple x64 shellcode for Windows.

The target

We are going to practically evaluate a situation in which we, as attackers, attained DBA-level (Database Administrator) access to a PostgreSQL instance. All the steps documented here were conducted in a dedicated lab environment, something we should always build to reflect our production targets for the purpose of research and development.
Basic version and configuration information:

For convenience, I am going to use the GUI Pgadmin tool that comes together with the entire PostgreSQL package (on my system it's under C:\Program Files\PostgreSQL\17\pgAdmin 4\runtime\pgAdmin4.exe) as the client. In the following screenshot, I am already logged in as a DBA, confirming the privileges held and the database version using the query tool:

Initial code execution

Users holding DBA privileges usually have access to built in functions allowing arbitrary file operations and arbitrary code execution, an obvious choice for anyone willing to abuse DBA access for offensive purposes. An example of the former is COPY (https://www.postgresql.org/docs/current/sql-copy.html); "COPY moves data between PostgreSQL tables and standard file-system files". But we are going to jump directly to the latter; code execution.
On PostgreSQL it boils down to defining a stored procedure implemented in a shared library (a DLL on Windows). Or, to be more exact, all we really need is the ability to tell PostgreSQL to load an arbitrary DLL when trying to find the implementation of a stored procedure we are trying to create by properly calling "CREATE OR REPLACE FUNCTION". More information on how this mechanism works in PostgreSQL can be found here: https://www.postgresql.org/docs/current/xfunc-c.html.
The basic syntax looks like this:

CREATE OR REPLACE FUNCTION foo(int) RETURNS int AS 'PATH_TO_DLL.dll', 'WinExec' LANGUAGE C STRICT;

Now, if we look into the reference above, we will find that to work properly with PostgreSQL, our DLL has to meet some requirements: "To ensure that a dynamically loaded object file is not loaded into an incompatible server, PostgreSQL checks that the file contains a “magic block” with the appropriate contents. This allows the server to detect obvious incompatibilities, such as code compiled for a different major version of PostgreSQL. To include a magic block, write this in one (and only one) of the module source files, after having included the header fmgr.h: PG_MODULE_MAGIC;".

This is true for normal implementations, but what we're doing here is not quite normal. All we are really interested in is arbitrary code execution, it does not have to be elegant, or complicated. If the service executes the DLL's entry point (by default it is a function called DllMain()), and we are OK with placing our code there, we're good. But keep in mind that this approach can cause issues difficult to troubleshoot.

For the purpose of testing this, we're going to load the following test DLL into the service process:

It will simply grab the current user, command line and then attempt to write that information into a newly created (or appended, if already existing) text file, nothing else. It's not super elegant, but it does its job.

All the source code described here can be found at https://github.com/ewilded/process-injection-postgre.

Once we have a compiled DLL, we:

  1. deploy it into C:\Users\Public\basic_info.dll (or any other publicly readable location),
  2. invoke "CREATE OR REPLACE FUNCTION foo(int) RETURNS int AS 'C:\Users\Public\basic_info.dll', 'WinExec' LANGUAGE C STRICT;":

We can see an error message about the missing magic block, which was expected as we did not bother to make this DLL compatible with PostgreSQL by putting its "magic block" there. But we can also see that the file was created anyway, which means that DllMain() executed successfully before the file was checked for the presence of the magic block. So from our perspective it does its job.
Let's open the file and see the contents:

Great, so far so good! We can see that we successfully injected code into a process named postgres.exe, that was invoked from an executable located at C:\Program Files\PostgreSQL\17\bin\postgres.exe, with the following command line: "C:/Program Files/PostgreSQL/17/bin/postgres.exe" --forkchild="backend" 5316, running as user DESKTOP-P70C341$.

Privilege escalation

Now, the value returned by the GetUserName() function is a bit misleading here. It suggests the computer account, but it is not "SYSTEM". In the very first screenshot, the one depicting the service properties in the services.msc console, in the top-right corner we can see that the service is run as "Network Service". Let's have a look at the owner of the get_basic_info.txt file we just created:

So, the NT AUTHORITY\NETWORK SERVICE account (https://learn.microsoft.com/en-us/windows/win32/services/networkservice-account) is a more limited non-personal user in Windows, preferred for running network-exposed services instead of using the most powerful NT AUTHORITY\SYSTEM account. This is done in an attempt to prevent an attacker from gaining full control over the system in case they managed to take over such a service. And here, as usual, we want to get SYSTEM.

By the way, when attacking services this way, we might often end up in a thread impersonated (https://learn.microsoft.com/en-us/windows/win32/secauthz/impersonation-tokens) to a less privileged user, especially when the service performs some operations on their behalf. In such case, we should add a simple call to RevertToSelf() into our DllMain(), before any other operations. This will switch the thread back to the primary token of the process. We might already be executing code in a process running as SYSTEM without knowing it, and being one simple RevertToSelf() away from attaining it. This, however, is not the case here.

While there are numerous ways a local user can attempt privilege escalation, there is a group of vectors specific to service accounts such as NETWORK SERVICE and LOCAL SERVICE. I am talking about attacks involving the SeImpersonate privilege, most of which are commonly known as "potato privilege escalation attacks" (HotPotato, RottenPotato, JuicyPotato, SweetPotato, GodPotato), but also a vector involving impersonation of a token stolen from the RPCSS service, as described in this article https://decoder.cloud/2020/05/04/from-network-service-to-system/, based on https://www.tiraniddo.dev/2020/04/sharing-logon-session-little-too-much.html (POC: https://github.com/decoder-it/NetworkServiceExploit). Whatever the case may be, all of them require execution from a token that has the SeImpersonate privilege present.

Stripped tokens

In the case of PostgreSQL described here, I learned the hard way that the process we just injected our DLL into (postgres.exe) does not have this privilege, contrary to the privileges that processes created by NETWORK SERVICE have by default. Let's have a deeper look into the process tree that makes up the service and inspect the relevant security tokens (using Sysinternals Process Explorer). We already know that the process we injected our DLL into is named postgres.exe. And - from the very first screenshot in the article, showing the service properties - that the executable started by the service manager is pg_ctl.exe. Let's see the entire process tree:

So we can see that pg_ctl.exe has one child process named postgre.exe, which itself has several other child processes also named postgres.exe.
Now, let's enter the properties of any postgres.exe process and inspect its security token in the "Security" tab:

As we can see, there is only one privilege present in the token - SeChangeNotifyPrivilege, which corresponds to traverse checking and notifications about changes in files and registry, but there's no SeImpersonatePrivilege present.
Now, let's inspect the same tab for pg_ctl.exe:

OK, so we can clearly see that while pg_ctl.exe is running with a primary token holding the usual set of privileges typical to NETWORK SERVICE, the process we can execute code within is not. Its primary token is stripped from additional privileges such as SeImpersonatePrivilege.

Possible workarounds

So what can we do? At the time when I was faced with this challenge, the only and immediate idea I had was process injection. A couple of weeks ago, though, I came across this neat article https://itm4n.github.io/localservice-privileges/, in which the author resolved the same problem by creating a scheduled task. Which in turn inspired me with more ideas for alternatives, but that's a subject for a separate article.

Process injection

This activity is usually performed by attackers for evasive purposes - to hide their own malicious code in a legitimate process (https://attack.mitre.org/techniques/T1055/). It boils down to one process modifying another one by inserting its own code into it. So after successful injection the modified process, in addition to the code originating from the executable it was invoked from, will contain additional code injected by a different process. Since both pg_ctl.exe and postgres.exe are owned by the same user (NETWORK SERVICE) and have the same integrity level (System), it is possible to do this. To see a detailed explanation of the access control process here, go to the last section of this article. Meanwhile back to process injection!

So what exactly is being injected and how? As we are dealing with live processes, we have to inject position independent machine code - commonly referred to as shellcode - although in this case the code we will be injecting is not going to invoke a shell. When it comes to the method, there are many (please refer to https://attack.mitre.org/techniques/T1055/ for details), but we are going to use the most common one. The algorithm is as follows, all actions are performed by the process we currently control - postgres.exe:

  1. Open a handle to pg_ctl.exe with PROCESS_ALL_ACCESS (or PROCESS_CREATE_THREAD|PROCESS_VM_OPERATION|PROCESS_VM_RIGHT would suffice),
  2. Allocate a new memory section with PAGE_READWRITE permissions in the address space of the pg_ctl.exe process.
  3. Write the shellcode into the newly allocated memory section.
  4. Change the flags of the memory section containing shellcode from writable to executable+readable.
  5. Start a new thread in the pg_ctl.exe process, pointing the beginning of the shellcode memory section as its entry point.
  6. Close the handle.

This is how it is implemented in one function in C, using WinAPI:

Before calling this function, the PID of the pg_ctl.exe process will be fetched and provided through the process_id argument.

Obviously, since this is the most common process injection algorithm, it should be picked up by any good EDR. While EDR evasion is a subject for a separate article (or rather a whole book!) and there are numerous different ways to approach this challenge, my personal favorite is https://github.com/xuanxuan0/DripLoader.

Shellcode

OK, now let's talk about shellcode. Of course we could just generate some with one of many available tools and if that is what you want, then go ahead. Here, instead, let's build one from scratch, as the ultimate goal is to understand how things work. Also, the simpler and more custom our shellcode is, the harder it is to create an accurate signature allowing to identify it as something malicious.

Another reason to keep it simple is the fact that writing shellcode from scratch is an arduous task. Remember, the only reason we are using shellcode in the first place is because we decided to jump into another process, solely because of the permissions its primary token holds. The goal is to move further execution logic to that process, but it does not mean we have to implement the entire following execution chain as shellcode, or even that we have to stay in that process. We just need to use it as a trampoline to jump to a more powerful primary token.
The simplest shellcode I could think of doing exactly this was just one function call:
LoadLibrary("C:\Users\Public\get_system.dll");
whereas the rest of the exploitation chain will be located in that DLL, which we can implement and compile the same way as everything else (not as shellcode).

So the entire shellcode will boil down to calling only one function with one argument, at least on the high level.
But in machine code, we will have to do a bit more to make it happen.
First, we have to provide the argument (a string containing the path to the DLL file).
Second, we need to know the address of the function to call.
In this case that will be the base address of the kernel32.dll shared library (which is already loaded in every process we might inject into, so we will not be actually loading it into the process, just resolving its current address), plus the RVA (Relative Virtual Address) of the LoadLibrary() function in that DLL (its offset from the base address). We will store that address in a CPU registry (for example RDX), so the final instruction will be "CALL RDX". In x64 function arguments are passed via CPU registers, the first, and in our case the only one, being RCX. So we will put a string pointer into RCX, the current address of LoadLibrary() into RDX and we'll CALL RDX.

Let's start with obtaining the current adress of the LoadLibrary() function and copy it into RDX.


Now this is where differences between operating systems come into play.
When we produce executables from the source code, the code we write is first compiled into opcodes (machine code), and then linked with external dependencies (such as LoadLibrary()). Writing shellcode for Linux is easier, because when it comes to basic system functions (syscalls), it is easy to call them directly by their numbers, as those are consistent across versions. On Windows, we must fetch that information first, regardless to whether we choose to use direct system calls or higher-level WinAPI functions exported by DLLs such as LoadLibrary() from kernel32.dll. Using direct system calls is usually done for evasive purposes to bypass user-mode hooking set up by EDRs and the like (https://redops.at/en/blog/exploring-hells-gate is one such technique), and we are not taking this route here. We will simply call LoadLibrary() from kernel32.dll. But using higher-level WinAPI functions provided by system DLLs also requires dynamic resolution of their current addresses, as those can't be predicted because of ASLR and because of the differences between particular system and library versions. Thus, position independed shellcode for Windows requires embedded logic to dynamically resolve those addresses. But since we are not dealing with remote code execution here, but a local one, we do not even have to build a position independent shellcode.

To better understand this, let's have a look at the memory layout of the processes we are trying to jump between.
For this purpose, let's use x64dbg, run as administrator. We must be elevated to have the SeDebugPrivilege privilege so the debugger can attach to a process running as a different user (NETWORK SERVICE), not to mention its integrity level (System). First, let's attach to pg_ctl.exe and looking at the "Memory" tab to see the allocated sections. We can see the current base address where kernel32.dll is loaded: 00007FFEECB50000.

Now, we go into the "Symbols" tab, we select kernel32.dll, then on the right we have the list of exported functions, we can apply a filter to make the search easier - we find "LoadLibraryA" and note its address: 00007FFEECB70830.

By the way, if you're wondering about the difference between LoadLibrary(), LoadLibraryA() and LoadLibraryW(): eventually both LoadLibraryA() and LoadLibraryW() will call the same system function, but depending on the suffix we are either invoking the version that expects the argument to be in ASCII (LoadLibraryA()), or its wide-character (https://learn.microsoft.com/en-us/cpp/c-runtime-library/unicode-the-wide-character-set?view=msvc-170) counterpart LoadLibraryW(). We want to keep things simple, so we choose the ASCII variant.

So the RVA of LoadLibraryA() in this version of kernel32.dll is 20830 (function address 00007FFEECB70830 - base address 00007FFEECB50000). OK, now we detach from the pg_ctl.exe process (it is important to use "File -> Detach"; if we simply close x64dbg we WILL CRASH the process we are attached to!) and then attach to one of its child processes named postgres.exe. Again, we look up the address of LoadLibraryA() from kernel32.dll and compare the results:

We can see that in both processes the base address of kernel32.dll, and thus also the address every single function it exports, are the same. This means that we do not have to resolve that address in our shellcode. Instead, we can conveniently resolve the address by calling GetProcAddress() from the same DllMain() function we will use to inject the shellcode from, save it into a pointer and then dynamically embed it into the shellcode, copying the pointer byte by byte into the shellcode buffer section where the argument is expected as opcode.
If this seems complicated, it should become more clear later when the structure of the shellcode is visually demonstrated.

But first, let me introduce you to shellnoob (https://github.com/reyammer/shellnoob), a very handy tool I use to manually convert between opcodes and assembly and vice versa.
Let's run it in x64 mode (x86 is the default), in interactive mode:

$ python3 shellnoob.py --64 -i
It will prompt for the mode to be used:
asm_to_opcode (1) or opcode_to_asm (2)?:


We choose 1, and then we are prompted to write assembly (the default is AT&T syntax, but we can also use the --intel switch when invoking it). Here I am demonstrating how to obtain the opcode for a MOV operation putting a 64-bit number expressed in hexadecimal (0x1122334455667788) into the RDX register:
asm_to_opcode selected (type "quit" or ^C to end)

>>movabs $0x1122334455667788,%rdx
movabs $0x1122334455667788,%rdx ~> 48ba8877665544332211

So, 48ba8877665544332211 is the hexadecimal representation of the opcode bytes corresponding to this instruction.
As you might have noticed, first comes the instruction (2 bytes - \x48\xba representing MOVABS to RDX), and it is followed by the number (argument for the instruction), with its bytes in byte-reversed order (little-endian).
So this is how assembly becomes machine instructions. Here we have a 10-byte opcode, whereas the first two bytes carry the instruction to execute, while the remaining 8 bytes contain the argument for that instruction. Opcodes for individual instructions and operands vary in length. For example, the NOP instruction is just one byte (0x90) and takes no arguments.
Now, back to our movabs $0x1122334455667788,%rdx. If we want to replace this number with the address of the LoadLibraryA() function (or any other address), we just need to replace those 8 bytes in that part of the shellcode, before we inject it into pg_ctl.exe. So instead of 8877665544332211 in 48ba8877665544332211, we will be using 0000000000000000 (that's right, eight NULL bytes) as placeholders. This way the shellcode template will be more readable in the final POC code. So for now, the first instruction in our shellcode is is 48ba0000000000000000 (which, unsurprisingly, corresponds to movabs $0x0,%rdx).

Now, we need to provide the string containing the path to the DLL we will be loading. We will push it onto the stack and then copy a pointer to it (being the current value of the RSP register once we are done pushing) into RCX, because RCX is the register used to pass the first function argument on the x64 architecture. LoadLibraryA() takes only one argument. Otherwise we could not use the RDX register to store the address of the function to call, because on x64 RDX is where the second function argument is passed.
Now, let's convert the "C:\Users\Public\get_system.dll" string into ASCII:
$ echo -n 'C:\Users\Public\get_system.dll' | xxd
00000000: 433a 5c55 7365 7273 5c50 7562 6c69 635c C:\Users\Public
00000010: 6765 745f 7379 7374 656d 2e64 6c6c get_system.dll

So we have the following sequence of bytes: 43 3a 5c 55 73 65 72 73 5c 50 75 62 6c 69 63 5c 67 65 74 5f 73 79 73 74 65 6d 2e 64 6c 6c. We must also remember to add the terminating NULL byte.
It's 30 bytes of the actual name plus one NULL byte. So, we'll add one extra NULL byte to make this 32, because we will have to split this string into 4 separate PUSH instructions. Why 4? Because on a 64-bit system, the operand size is 64 bit, which means it carries 8 bytes and we have to round up the size of the buffer to the closest number dividable by 8, and the closest one is 32. After splitting, we push each one of those 4 chunks on the stack. It is a bit tricky, because we have to do it in the reversed order.
So we take this:
43 3a 5c 55 73 65 72 73 5c 50 75 62 6c 69 63 5c 67 65 74 5f 73 79 73 74 65 6d 2e 64 6c 6c 00 00
Split it into 4 chunks, in a sequence starting from the last 8 bytes of the string (easy to recognize by the NULL bytes):
65 6d 2e 64 6c 6c 00 00 em.dll
67 65 74 5f 73 79 73 74 get_syst
5c 50 75 62 6c 69 63 5c \Public
43 3a 5c 55 73 65 72 73 C:\Users

An important note: this is the order in which we the bytes will end up in our final shellcode, as arguments to instructions. So they are easy to use when building shellcode fully manually. However, if we generate opcodes with shellnoob (as we will usually do at least for the first instance of the given instruction), the order of these bytes will be reversed. Take this output from shellnoob as an example. It copies the string 'get_syst' to the RAX register:

It is easy to forget and confuse these nuances, I myself more than once had to rewrite my shellcode only after seeing in the debugger that the string on the stack was in the wrong order.

Now, why am I even putting these strings into the RAX register instead of using the PUSH instruction directly?
Because of another caveat of the x64 architecture; we can't directly call the PUSH instruction with a static 8-byte operand (on x86 we can do the same for 4-byte operands). Instead, we will have to use a register (e.g. RAX) as an intermediate. So we will have a sequence of 4 pairs of the following instructions:
movabs 8-bytes-of-string-literal,%rax
push %rax

The opcode for 'push %rax' is 50:
$ python3 shellnoob.py --64 -i
asm_to_opcode (1) or opcode_to_asm (2)?: 1
asm_to_opcode selected (type "quit" or ^C to end)

>>push %rax
push %rax ~> 50

OK, so we already know that the opcode for putting a byte literal (MOVABS) into RAX is 48b8 + string, and we know the string argument expressed assembly is reversed - but it is back in the right order in the final shellcode. So instead of using shellnoob for every single string (and having to reverse the byte order in the assembly), it's better to create those opcodes manually by simply using the final byte order (added spaces and strings on the right for readability):

Then we will copy the value of RSP (the current stack pointer pointing at the beginning of our string) into RCX (where the pointer to the DLL path will be expected by LoadLibraryA()):

>>mov %rsp,%rcx
mov %rsp,%rcx ~> 4889e1

And finally, to CALL RDX, where LoadLibraryA() address will be stored:

>>call *%rdx
call *%rdx ~> ffd2

Therefore, putting this all together, our shellcode would be:

Stack alignment

But this is not the end; the shellcode above is ALMOST correct, but still it WILL fail.
When I was building it, I was simply porting the x86 version (which I originally developed weeks ago) and that's how I learned about the differences. The x86 was simpler not only because I could push 4-byte string literals on the stack directly, without having to use a register as an intermediary. It was also because of the differences in the calling convention. So I knew that in x86 we pass function arguments through the stack, while on x64 we use registers (RCX, RDX, R8, R9) for the first 4 integer arguments. However, the stack is still used and there is a requirement to align it to 16 bytes (https://learn.microsoft.com/en-us/cpp/build/x64-calling-convention?view=msvc-170). In practice, I experienced the shellcode above failing, with the string argument being overwritten by the LoadLibraryA() function itself (which I figured out once I started debugging it). So eventually I compiled a simple 64-bit test application myself, which simply called LoadLibraryA() on a sample DLL, then I looked into it using x64dbg and saw that the call to the LoadLibraryA() function was surrounded by stack alignment operations; sub $0x28,%rsp before and add $0x28,%rsp after:

So, this is how the shellcode looks like after fixing this (I also added xor %eax,%eax to set the return value to 0, just like in the executable in the screenshot above):

Now, there is just one more thing we want to add. If we simply inject this shellcode into a newly remotely allocated memory section and start a thread on it, after executing LoadLibraryA("C:\Users\Public\get_system.dll") it will keep executing whatever follows it in the memory section - which will be NULL-bytes, being interpreted as instructions referring to an invalid address (causing access violation). Even if we filled them with the value of 0x90 (NOP), eventually the instruction pointer would cross the end of the section and attempt to execute code outside of it, also causing an access violation. That would crash the entire pg_ctl.exe process, and we don't want that. Thus once our DLL is loaded, we want to gently exit the thread by adding a call to ExitThread(0) into our shellcode.

Just like with LoadLibraryA(), we will fetch the address of ExitThread() and imprint it into the shellcode dynamically, calling it the same way.

Opcodes we'll need:

>>movabs $0x0,%rax
movabs $0x0,%rax ~> 48b80000000000000000
(these 0-s are just placeholders, will be replaced with ExitThread addr)

>>movabs $0x0,%rcx
movabs $0x0,%rcx ~> 48b90000000000000000 (exit code, into RCX, that's where the argument goes)

>>call *%rax
call *%rax ~> ffd0 (call RAX -> call ExitThread(0))

Opcodes in hex:
48b80000000000000000
48b90000000000000000
ffd0

This is how the corresponding shellcode buffers look like in the final POC:

And this is how we can dynamically fetch the addresses of LoadLibraryA() and ExitThread():

And then put them in place of those NULL byte placeholders in the buffer:

get_system.dll

Now finally, the DLL we will load into pg_ctl.exe. This code (just like the shellcode) will already be executing in a process with its primary token holding the SeImpersonate privilege. So from this point we are free to execute any exploitation path we want, regardless to whether its logic will be implemented in a DLL, or a separate EXE that we simply invoke with CreateProcess(). Any child process will inherit the primary token, so the privilege will be available.
We could, for example, create a new process, using a slightly modified version of the https://github.com/decoder-it/NetworkServiceExploit. One not taking any arguments and automatically impersonating the SYSTEM token. That's what I initially did when I was finishing the x86 version of the poc.

When writing and testing this one, I simply copied the basic_info.dll as get_system.dll, just to prove successful injection into the pg_ctl.exe process:

And here's the debug log file postgres_poc_log.txt:

Code: https://github.com/ewilded/process-injection-postgre.

Also, be careful when implementing any logic in DllMain(). For example, if any operation called from that method results in triggering another dependency load, you will end up with a deadlock (https://devblogs.microsoft.com/oldnewthing/20040128-00/?p=40853).

Access control

Earlier I mentioned that since both postgres.exe and pg_ctl.exe have the same owner and the same integrity level, it is possible for one to overwrite another. The statement is generally true, but deserves a deeper explanation. The call to OpenProcess() with PROCESS_ALL_ACCESS was allowed not because of these two properties, but because of an explicit ACE entry in the target process's security descriptor.
Let's use Process Explorer to see the permissions set on the pg_ctl.exe process:

There are two ACE entries. One for the built in Administrators group, and one for LogonSession_0_131433. And surprisingly, Administrators are only allowed to query information, while LogonSession_0_131433 has full control:

The reason why we succeeded at overwriting pg_ctl.exe from postgres.exe was not the fact that the user in the primary token of the postgres.exe process matched the owner of the target, but the ACE entry above, in conjunction with the presence of the SID of the same logon session in the primary token of the source process:

Now, what if postgres.exe did not have this SID in its primary token, or if there was no ACE entry allowing this SID to have this level of access? Well, an attempt to open the process with PROCESS_ALL_ACCESS would be denied. In other words, just because we are the owner of the resource, does not mean that every operation we request on it will be automatically allowed.
However, as long as we are operating from the user who is also the owner of the target process and from the same integrity level, we can modify its ACE entries, which make the security descriptor's DACL (Discretionary Access Control List). In Windows access control terms, a WRITE_DAC request will be allowed. To test and confirm this, I created a small POC (which can also be found in the same Github repository).
It grabs the process ID of pg_ctl.exe and attempts to open it with PROCESS_ALL_ACCESS. If that fails, it opens it with WRITE_DAC, overwrites the security descriptor with NULL DACL (which effectively leads to all access being granted - it's not elegant, but it's funny), and then confirms that opening the process for writing is now possible.
First, I ran the POC from a normal user win10, only to confirm that both operations would be denied because it does not own the process:

Then the same is attempted from NT AUTHORITY\NETWORK SERVICE (spawned with Psexec):


For an additional confirmation, let's inspect the target process's permissions in Process Explorer:

By the way, for a very in-depth explanation of Windows security internals, I highly recommend https://nostarch.com/windows-security-internals.
Until next time!

No one really cares about cookies and neither do I