ASIS CTF 2020 - sshateau (285pt)

sshd backdoor

July 5, 2020
rev ssh

sshateau (285pt, 11 solves)

Rev

Description:

Recently, I realized that the ark of the covenant is in a palace in the sky which is protected by 19 special angels! I am so curious to see the ark but I need to introduce myself as an insider with a watchword… Would you mind helping me enter this palace, please?

Palace address: 76.74.170.201:2020

Files:

Overview:

We are given a sshd binary and a remote address to connect to. When we try to ssh at this address, sure enough we connect and are prompted for a password:

$ ssh 76.74.170.201 -p 2020
The authenticity of host '[76.74.170.201]:2020 ([76.74.170.201]:2020)' can't be established.
ECDSA key fingerprint is SHA256:UPVqys5AOxQwfeh6shfz8Stg93agzYYZedQS4j75UBw.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[76.74.170.201]:2020' (ECDSA) to the list of known hosts.
[email protected]'s password:

From the challenge description it sounds like there is a backdoor hidden in this version of sshd and we need to find it.

Part 0: reference binary

The sshd binary is very large and was compiled without debugging symbols, in order to get our bearings it will be useful to try to compare it to a “clean” version of sshd so we can see what parts were modified. Additionally for the purposes of finding the patch, it will also be useful to have a reference sshd that was compiled with similar settings so the patch stands out.

In the usage printout, we see:

OpenSSH_8.3p1, OpenSSL 1.1.1  11 Sep 2018
usage: sshd [-46DdeiqTt] [-C connection_spec] [-c host_cert_file]
            [-E log_file] [-f config_file] [-g login_grace_time]
            [-h host_key_file] [-o option] [-p port] [-u len]

8.3p1 is the ssh version here and we can download a tarball of source from any of these mirrors: https://www.openssh.com/portable.html#downloads. Specifically we want openssh-8.3p1.tar.gz.

After unpacking the directory, we can compile two reference versions:

With debugging symbols:

./configure CFLAGS='-g'
make

Without debugging symbols (for hexdiff):

./configure
make

I’ve attached my two versions: with symbols and without symbols.

Part 1: finding a patch

The next step is to try to figure out what has been changed in the custom binary. I opened up the provided sshd and the reference compiled without symbols in Hex Fiend (a hex utility app) and compared them.

For the most part, the binaries were very similar and most of the changes were simply the LSB of an address. The reference binary was a bit bigger (likely compiled with extra features) and I found a few sections that were not present in the provided sshd.

Eventually I stumbled upon a large chunk of high entropy data that was found in the custom sshd and not the reference:

diff

I opened the binary with Binary Ninja and found this section:

secret

Sure enough it’s being referenced by some other functions, including this suspicious looking one:

sus

At this point I got a bit lost going through function xrefs so I started reversing from a different angle.

Part 2: auth entrypoint

Coming into this challenge I had very little knowledge about the internals of ssh/sshd. The extent of my knowledge with the authorization part was that it’s possible to authenticate with different schemes (e.g. password, encryption key, etc…) and that it’s also possible to configure and/or disable those schemes on the server side depending on how sshd is configured.

Knowing this, if I wanted to add a backdoor to ssh, the easiest possible place would probably be to modify one of the functions that does the actual authentication. For example, we would be looking for a check_auth() function somewhere in the code.

I started searching for “auth” functions and eventually found static int userauth_passwd(struct ssh *ssh) which seems promising. Source is here: https://github.com/openssh/openssh-portable/blob/9b47bd7b09d191991ad9e0506bb66b74bbc93d34/auth2-passwd.c#L51-L71

This does some checks and potentially calls auth_password(ssh, password) to continue the authentication. The string "password change not supported" is used here and we can search for that in the reference sshd binary to locate this function (I’ve renamed internal functions here):

auth

Sure enough if we run sshd and set a breakpoint at +0x23e0e, it triggers as soon as we enter a password on the client side!

At this point the function looks unchanged from the source, so let’s dig further…

Part 3: digging further

I tried setting a breakpoint inside the auth_password function but it didn’t trigger, hmmm…

If we look at the source we see there are actually two options: it can either call auth_password or mm_auth_password.

I tried breaking in mm_auth_password and it worked. Looking at the source (https://github.com/openssh/openssh-portable/blob/1a7217ac063e48cf0082895aeee81ed2b8a57191/monitor_wrap.c#L390-L425) we see that this has the same signature as auth_password but instead of handling the authentication locally, it sends an IPC request containing the password via mm_request_sent and recieves an authentication response via mm_request_receive_expect.

So how are these requests handled?

After some more digging, I found a whole bunch of mm_answer_* functions including mm_answer_authpassword which sounds promising. However, setting a breakpoint here didn’t seem to trigger.

Eventually I found some documentation and realized that the sshd binary (ssh daemon) spawns two processes during a connection ([priv] and [net]). The net process handles client/server communication (and seems to be sandboxed) and the priv process (or “monitor” process) can handle the actual authentication.

So when we try to enter a password, the net process takes our request and communicates with the priv process to verify it.

Sure enough, if we start an ssh connection and then attach gdb to the priv process, we can break in mm_answer_authpassword (at +0x27500).

Part 4: password authentication

If we keep digging in mm_answer_authpassword, we see that it calls auth_password which in turn calls sys_auth_passwd (+0xfb10);

Here is where we start to see differences:

Reference version:

ref

Custom version:

custom

The original version basically does xcrypt(password, salt) and compares this to the user’s shadow file data. The custom version does this but also if this fails and we are able to reach a certain branch, it may call a different function, that I’ve named custom_auth(char *username, char *password), and if that returns true it will authenticate anyways.

Note: I didn’t realize it at this stage, but this branch is reached by checking sha256(username). The sha256 function was actually the strange function I found at the beginning! If you every find a massive function like that, you can usually google constants and see if any crypto functions come up.

Part 5: custom auth

The custom_auth function is at +0xf5a0 and basically does a bunch of condition checks on different characters in the flag and username:

custom_auth

We can encode these constraints into z3 fairly nicely:

from z3 import *

s = Solver()

def mul(a,b):
    aa = ZeroExt(32,a)
    bb = ZeroExt(32,b)
    return aa * bb

def mul3(a,b,c):
    aa = ZeroExt(32,a)
    bb = ZeroExt(32,b)
    cc = ZeroExt(32,c)
    return aa * bb * cc

sz = 34
p = [BitVec('p%d' % i, 8) for i in range(sz)]
u = [BitVec('u%d' % i, 8) for i in range(6)]

s.add(mul(p[0], p[sz//2-1]) == 0x1d93) # 0000f747
s.add(u[2] == p[sz//2-1]+4) # 0000f79b
s.add(mul(p[0], p[sz//2-2]) == 0x174b) # 0000f7d7
s.add(p[sz//2+sz//2-1] == u[0]) # 0000f7e9
s.add(mul(p[sz//2-2], p[2]) == 0x21b9) # 0000f813
s.add(u[4] == p[2] + 4) # 0000f848
s.add(mul3(p[2], p[1], p[sz//2-3]) == 0xca745) # 0000f896
s.add(p[sz//2+2] == u[3]) # 0000f8a5
s.add(mul3(p[sz//2-4] - 0x20, p[3], p[4]) == 0x7c4d5) # 0000f90c
s.add(p[4] == u[1]) # 0000f91d
s.add(mul3(p[sz//2-5], p[5], p[sz//2-6]) == 0x96a6d) # 0000f980
s.add(mul3(p[6], p[sz//2-6], p[sz//2-7]) == 0xed8f3) # 0000f9c7
s.add(u[5] == p[sz//2+6]) # 0000f9d6
s.add(mul(p[7], p[3]) == 0xeb3) # 0000f9ff
s.add(mul(p[sz//2-8], p[5]) == 0x10d3) # 0000fa30
s.add(mul(p[sz//2-9], p[sz//2-4]) == 0x3116) # 0000fa61

for i in range(sz//2):
    s.add(p[sz//2+i] == p[i] + 1)

s.check()
m = s.model()

password = ''.join([chr(m[p[x]].as_long()) for x in range(sz)])
user = ''.join([chr(m[u[x]].as_long()) for x in range(6)])

print(user)
print(password)

This gives:

reuben
CgaGeIm5z;qOkgSYqDhbHfJn6{<rPlhTZr

Side note: originally I messed up one of the constraints and got reuHer as the username which didn’t work. I realized the outer function actually computes sha256(username) == 73c96f96c65b6372b122a543aecb54fbfd8808ce11cc276edd9eb13fee7bfe3e and I was able to use crackstation.net to get reuben

Step 6: flag!

To get the flag, we just need to use these credentials we discovered:

$ ssh [email protected] -p 2020
[email protected]'s password: <type password>
Last login: Sun Jul  5 10:11:24 2020 from 172.17.0.1
ASIS{F0r_ev3ry_0ne_th4t_a5ke7h_r3ceive7h_4nd_h3_th4t_s33ke7h_f1nde7h_4nd_t0_h1m_th4t_kn0cke7h_1t_5hall_8e_0pen3d}
Connection to 76.74.170.201 closed.

Conclusion

Overall I thought this problem was pretty interesting. It was definitely more realistic than a lot of the ctf reversing challenges I’ve seen.

I think this type of challenge could have been made more interesting/challenging by requiring the use of some other ssh protocol options (eg. adding a custom backdoor authentication protocol that requires you to modify your ssh client). This challenge was essentially a crackme hidden inside sshd.

Alternatively, the “backdoor” could have been added in an underhanded way by patching in some sort of bug to the protocol (e.g. something in the vein of heartbleed). That kind of challenge might be too long for a weekend ctf but maybe with a provided diff it would be possible.

I’m also curious about tools that can automatically infer symbols by comparing a stripped binary to a similar reference binary. I didn’t end up needing to use something like that because this patch was very localized but I’d like to explore how to automate that process.

comments powered by Disqus