NNS CTF: noters, rust logic bug

Challenge Overview

  • Source: https://github.com/fslaktern/noters

  • Name: noters

  • Category: pwn

  • Difficulty: Medium

  • Flag: NNS{y0u_d1dnT_n33d_uns4f3_t0_uns4f3}

  • Description:

    A blazingly-fast, memory-safe, CRUD-compatible note-taking app written in nearly 1000 lines of safe Rust. No unsafe and no memory errors, yet still a pwn challenge. Everything looks safe. Everything compiles. Even clippy is happy. But something still feels off, and it’s not Rust’s fault.

  • Handout: Entire source

I wrote this one for NNS CTF 2025. It’s a medium-sized (1000+ lines), modular Rust app with a realistic logic bug, which leads to being able to read other users’ notes.

The exploit below is the intended solution, but it doesn’t show how the bug is found, which is the interesting part. The players who solved it mostly read a bit of the code and then played around with the app, mainly with inline note references. The intended path isn’t to spot the typo in app.rs; it’s to read just enough to understand how the app works, then poke at it.

I put it in pwn rather than misc, since players with binary exploitation experience would recognize the high-level use-after-free pattern. I initially intended it to be easy, but the app size plus the assumed pwn experience pushed it to medium for established teams. The exploitation itself is trivial once you see the bug; finding it is the hard part, same as in real applications.

Vulnerability

There’s a logic flaw in delete_note() in app.rs, a high-level use-after-free. The intent was to refuse deleting a note that other notes reference, but the code checks the note being deleted for backlinks instead of checking other notes for references to it. The diff below patches delete_note().

176    /// Deletes a note by ID, but only if no other notes reference it.
177    ///
178    /// # Errors
179    ///
180    /// Returns:
181    /// - `NoteValidationError::NoteIsReferenced` if other notes reference the note being deleted.
182    /// - Backend errors if the note cannot be read or deleted.
183    pub fn delete_note(&self, id: u16) -> Result<()> {
184        // Check if any other note references this note (expensive)
185        // and do not stop at the first backlink, find all of them
186        let mut backlinks: Vec<u16> = Vec::new();
187        for partial_note in self.list_notes()? {
188            // Do not prevent deletion if note refers to itself
189            if partial_note.id == id {
190                // While we're here: Check if user is the owner of the note
191                // Make sure they can't delete a note they don't own
192                if partial_note.owner != self.user {
193                    return Err(NoteValidationError::PermissionDenied(partial_note.id).into());
194                }
195                continue;
196            }
197
198            // Read content and find all references
199            // Save ID to Vec if it contains a backlink
200            // to the note we're trying to delete
201-           let content = self.repo.read(id)?.content;
202+           let content = self.repo.read(partial_note.id)?.content;
203            let references = self.get_references(&content);
204            if references.contains(&id) {
205                backlinks.push(partial_note.id);
206            }
207        }
208
209        let num_backlinks = backlinks.len();
210        match num_backlinks {
211            0 => self.repo.delete(id),
212            _ => Err(NoteError::Validation(
213                NoteValidationError::NoteIsReferenced(backlinks),
214            )),
215        }
216    }

This tiny mistake (id vs partial_note.id) means that backlink checks are ineffective, and you can delete a note even if another note references it. A dangling pointer.

Additionally, read_note() in app.rs fails to verify ownership for referenced notes. So if a user references a note they don’t own, the system still resolves it.

 92    /// Reads a full note and expands any references in the content (e.g. `[[1]]` becomes the full text of note #1).
 93    ///
 94    /// # Errors
 95    ///
 96    /// Returns:
 97    /// - `NoteValidationError::PermissionDenied` if the user does not own the note or a referenced note.
 98    /// - `NoteValidationError::ReferenceNotFound` if a referenced note does not exist.
 99    /// - Other repository errors if reading from the backend fails.
100    pub fn read_note(&self, id: u16) -> Result<Note> {
101        let mut note = self.repo.read(id)?;
102
103        // Only allow owner read access
104        if self.user != note.owner {
105            return Err(NoteValidationError::PermissionDenied(id).into());
106        }
107
108        // Mapping references to note contents: [[1]] -> "Some content"
109        let placeholders = self
110            .get_references(&note.content)
111            .into_iter()
112            .map(|rid| match self.repo.read(rid) {
113                Ok(ref_note) => {
114+                   // Make sure user has permission to read referenced note
115+                   if ref_note.owner != self.user {
116+                       return Err(NoteError::Validation(
117+                           NoteValidationError::PermissionDenied(ref_note.id),
118+                       ));
119+                   }
120+
121                    let placeholder = format!("[[{rid}]]");
122                    let expansion = format!(
123                        ">>> #{} {}\n>\n> {}",
124                        ref_note.id,
125                        ref_note.name,
126                        ref_note.content.replace('\n', "\n> ")
127                    );
128                    Ok((placeholder, expansion))
129                }
130                Err(_) => Err(NoteValidationError::ReferenceNotFound(rid).into()),
131            })
132            .collect::<Result<Vec<(String, String)>>>()?;
133
134        // Expanding references: [[1]] -> Note #1's content
135        let expanded = placeholders
136            .into_iter()
137            .fold(note.content, |txt, (ph, exp)| txt.replace(&ph, &exp));
138
139        note.content = expanded;
140        Ok(note)
141    }

Exploit

To exploit this:

1. Create two notes

One regular note (#0), and one referencing the first (#1), like [[0]]:

Note #0

Name:
> first note

Content:
> hello!
> .

Note #1

Name:
> references #0

Content:
> Reference to first note:
> [[0]]
> .

List should look like this

 id | owner     | name 
----+-----------+--------------
 0  | fslaktern | first note
 1  | fslaktern | references #0 

2. Delete note #0

Despite the note #1 referencing note #0, deletion succeeds due to the broken backlink check.

 id | owner     | name 
----+-----------+--------------
 1  | fslaktern | references #0 

3. Create a new note containing the flag

This gets assigned ID #0, which is still referenced by note #1.

 id | owner                | name 
----+----------------------+--------------
 0  | Norske Nøkkelsnikere | flag 
 1  | fslaktern            | references #0 

4. Read note #1

The reference [[0]] resolves - revealing the contents of the new note:

-------------------------------
#1: references #0

Reference to first note:
>>> #0 flag
>
> NNS{y0u_d1dnT_n33d_uns4f3_t0_uns4f3}
-------------------------------