Bỏ qua

Front Door 1

Introduction

Hacktheon Sejong 2025

Category: Web

Write-up date: 07/05/2025

This is a server monitoring page.

Find the vulnerability.

Flag format: FLAG{_}

Point: easy

Source code analysis

When first access to the website, we receive a list of api request

img.png

let app = Router::new()
.route("/api", get(handlers::get_root_handler))
.route("/api/health-check", get(handlers::get_health_check_handler))
.route("/api/logs", get(handlers::get_logs_handler))
.route("/api/monitor/{info}", get(handlers::get_monitor_handler))
.route("/api/signin", post(handlers::post_signin_handler))
.route(
"/api/flag",
get(handlers::get_flag_handler).layer(middleware::from_fn(middlewares::authorize)),
)
.layer(middleware::from_fn(middlewares::tracing_session_id))
.layer(middleware::from_fn(middlewares::set_session_expiry))
.layer(session_layer);

Inside source code, only route /api/monitor/{info} and route /api/signin give us attack surface to deal with, route /api/flag when access requires us to log in so we pass the function for now.

pub async fn get_monitor_handler(Path(info): Path<String>) -> impl IntoResponse {
    let file_path = match PathBuf::from("/proc")
        .join(alias(&info).unwrap_or(info.clone()))
        .canonicalize()
    {
        Ok(path) => path,
        Err(e) => {
            tracing::error!("Error canonicalizing path: {}", e);
            return (StatusCode::BAD_REQUEST, "Invalid parameter").into_response();
        }
    };

    let is_in_proc = file_path.starts_with("/proc");
    let is_file = file_path.is_file();

    tracing::error!("{}, {}", is_file, is_in_proc);

    if !is_in_proc || !is_file {
        return (StatusCode::BAD_REQUEST, "Invalid argument").into_response();
    }

    let comps: Vec<_> = file_path.components().collect();
    if comps.len() > 4 {
        return (StatusCode::BAD_REQUEST, "Invalid argument").into_response();
    }

    if comps.len() == 4
        && comps.get(2) != Some(&Component::Normal(OsStr::new(&process::id().to_string())))
    {
        return (StatusCode::BAD_REQUEST, "Invalid argument").into_response();
    }

    let msg = match fs::read_to_string(&file_path).await {
        Ok(content) => parse_content(&info, &content).await,
        Err(err) => {
            tracing::error!("Failed to read file '{}': {}", file_path.display(), err);
            return (StatusCode::BAD_REQUEST, "Invalid argument").into_response();
        }
    };

    (StatusCode::OK, msg).into_response()

In the get_monitor_handler function, we can read any file in /proc as long as it is a file and start with /proc. Using this path traversal exploit, we can read all the environment var by accessing /proc/self/environ including GUEST_ID and GUEST_PWD, the username and password of guest user.

pub async fn post_signin_handler(
    session: Session,
    Json(body): Json<PostSigninBody>,
) -> impl IntoResponse {
    let guest_id = env::var("GUEST_ID")
        .ok()
        .unwrap_or(consts::DEFAULT_GUEST_ID.to_string());
    let guest_pwd = env::var("GUEST_PWD")
        .ok()
        .unwrap_or(consts::DEFAULT_GUEST_PW.to_string());

    let is_authed = guest_id == body.username && guest_pwd == body.password;

    if !is_authed {
        return (StatusCode::BAD_REQUEST, "Invalid username or password").into_response();
    }

    if let Err(err) = session.insert("auth", body.username).await {
        tracing::error!("Failed to insert session data: {}", err);
        return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to signin").into_response();
    }

    let session_dir = match tempfile::tempdir() {
        Ok(tempdir) => tempdir.into_path(),
        Err(err) => {
            tracing::error!("Failed to create session directory: {}", err);
            let _ = session.remove::<String>("auth").await;
            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to signin").into_response();
        }
    };

    let session_dir = match session_dir.canonicalize() {
        Ok(path) => path,
        Err(err) => {
            tracing::error!("Failed to canonicalize session directory: {}", err);
            let _ = session.remove::<String>("auth").await;
            return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to signin").into_response();
        }
    };

    if let Err(err) = session.insert("dir", session_dir).await {
        tracing::error!("Failed to insert session data: {}", err);
        let _ = session.remove::<String>("auth").await;
        return (
            StatusCode::INTERNAL_SERVER_ERROR,
            "Failed to store session data",
        )
            .into_response();
    }

    StatusCode::OK.into_response()
}

img.png

But wait, how can we read normal cpu or uptime file but when accessed self/environ, it's return nothing. Well let's look at the function parse_content

async fn parse_content(info: &str, content: &str) -> String {
    match info {
        "uptime" => parse_uptime(content),
        "idle-time" => parse_idle_time(content),
        "cpu" => parse_cpu(content),
        "mem" => parse_mem(content),
        _ => {
            tracing::warn!("Unknown info type: '{}', content '{}'", info, content);
            String::new()
        }
    }
}

It's only show the content of uptime, idle-time, cpu, mem but not other, so to read the content we have to rely on the log itself.

#[derive(Deserialize, PartialEq, PartialOrd)]
#[serde(rename_all = "lowercase")]
enum LogLevel {
    Error,
    Warn,
    Info,
    Debug,
}

pub async fn get_logs_handler(
    session: Session,
    Query(query): Query<GetLogsQuery>,
) -> impl IntoResponse {
    let session_id = session.id().unwrap_or_default().to_string();
    let level = query.level.unwrap_or(LogLevel::Error);

    match get_logs(&session_id, level).await {
        Ok(logs) => (StatusCode::OK, logs.join("\n")).into_response(),
        Err(_) => (
            StatusCode::INTERNAL_SERVER_ERROR,
            "Failed to get logs".to_string(),
        )
            .into_response(),
    }
}

The log handler function take 2 parameter, one is session_id, the other is Query<GetLogsQuery>. By default, the log only split out Error. That why we can't see the Warn log from the function parse_content. But when we add the paramlevel=warnnow the log will split out content ofself/environ` img.png

Authentication

Now using the environment leaked from the self/enviroment we can use it to log in the website.

img.png

After login successfully, we extract the flag using the /api/flag endpoint.

img.png

FLAG: FLAG{Me7Hod_Ch4iN1nG_1s_5o_COoo0Oo00oO0ol}