NNS CTF: noters, rust logic bug
Challenge Overview
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(¬e.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}
-------------------------------