TL;DR: Mình phát hiện ra một lỗ hổng trong bộ API của một nhà mạng cho phép nạp tiền vào bất cứ số điện thoại nào của nhà mạng đó mà không cần thanh toán.
Mở đầu
Thời còn nhỏ, khi chơi game các bạn có bao giờ mơ mộng đến những thứ như "hack tiền", "hack đồ" trong game, những thứ mà nôm na sẽ khiến bạn trở nên "giàu" hơn so với phần còn lại (khác với kiểu hack game bắn bách phát bách trúng nhé :v). Đã bao giờ bạn dùng lệnh coinage trong đế chế hay IFIWEREARICHMAN trong GTA? Cảm giác nhấn nút cái có đầy tiền quả thật rất sung sướng phải không? Nhưng ngược lại, có bao giờ bạn thất vọng khi thử "hack" mà chẳng nhận được gì chưa? Mình thì bị nhiều rồi, thử mấy trang quảng cáo "hack tiền" với mấy game online mà xem, tiền thì không có mà thậm chí còn mất cả tài khoản ấy chứ. Lớn lên mới biết người ta dựng lên những thứ như vậy chỉ để lừa bạn nạp thẻ hoặc chiếm thông tin đăng nhập của bạn (phishing), hay lừa bạn click vào quảng cáo trên website để kiếm lời (clickbait). Nếu có là lỗi thật thì thường cũng sẽ bị fix trong vòng một nốt nhạc, và chẳng ai nhắc đến nó nữa.
Nói ví dụ về game cho các bạn dễ hiểu. Bài writeup của mình cũng về vấn đề "hack tiền", nhưng thứ mà mình lấy được là tiền trong tài khoản điện thoại. Cũng là những mơ mộng ấy, cũng cảm giác hồi hộp như vậy, và với mình là cảm giác vui mừng khi thành công cũng như hào hứng khi được chia sẻ những dòng này, và hi vọng rằng những phát hiện của mình sẽ không làm bạn thất vọng.
Vì mục đích bảo mật và cũng để phân biệt với lỗ hổng trước đó mà mình tìm được (https://goo.gl/f83SAn), nên mình sẽ gọi bên công ty này là "Y". Tất cả tên gọi trong bài viết cũng như trong ảnh đều sẽ sử dụng tên này.
Lỗ hổng của Y nằm ở đâu?
Lỗ hổng của Y nằm trong bộ API của ứng dụng Android, khá giống với lỗ hổng của X mình đã đề cập ở trên. Tuy nhiên, vì app của Y bảo mật hơn (theo mình đoán là có SSL Pinning) nên mình không thể dùng Fiddler để capture request theo cách thông thường mà phải dịch ngược mã nguồn để phân tích.
Quá trình dịch ngược ứng dụng của Y
Để dịch ngược ứng dụng của Y, trước hết mình cần một bản apk của ứng dụng, có thể tìm một cách dễ dàng trên các trang lưu trữ như apkpure, còn cách mình hay dùng là pull trực tiếp từ Android thông qua adb.
Việc đầu tiên mình thường làm sau khi có apk đó là sử dụng 2 công cụ dex2jar và jd-gui để dịch ngược mã nguồn của ứng dụng về code Java, sau đó nghiên cứu những phần code chức năng để tìm ra lỗ hổng.
Sau khi dịch ngược ứng dụng của Y, mình được một cấu trúc thư mục mã nguồn như thế này:
Theo kinh nghiệm bản thân, mình tìm kiếm mã nguồn trong thư mục com/y/, tuy nhiên trong thư mục này chỉ gồm một file R.java vốn chỉ để chứa thông tin. Mình cũng đã đọc hết file này và y rằng không tìm được đoạn code nào có vẻ có giá trị :(.
Sau khi lục tung hết mấy thư mục mã nguồn mà không có kết quả, mình đoán rằng cần phải tiếp cận vấn đề này theo một hướng khác. Và đó cũng chính là lúc mình bắt đầu để ý đến từ khóa xamarin. Vì trước đó đã từng đọc qua nên mình biết Xamarin là một nền tảng cho phép lập trình ứng dụng di động bằng ngôn ngữ C# của .NET framework. Mình thì chỉ nhớ mấy thư viên của C# có phần mở rộng .dll thôi, nên là quyết định quay lại tìm xem trong apk có file .dll nào không.
Vì file .apk thực chất cũng chỉ là một file nén, nên mình đổi định dạng lại thành .zip và giải nén, mình lại được một cấu trúc thư mục thế này:
Mở thư mục assemblies, được một nùi file .dll thế này. Mình thì chú ý đến file Y.dll.
Để dịch ngược file .NET chúng ta cũng cần chơi theo kiểu .NET. Vì vậy mình dùng Telerik JustDecompile để mở và phân tích file Y.dll.
Đầu tiên, trong file .dll này, mình tìm được một hàm khai báo base API của server
Từ hàm này mình biết được một số thông tin như:
- Ứng dụng này giao tiếp với server qua HTTP requests.
- Định dạng dữ liệu JSON.
- Base API của server là https://y.com.vn/api/.
Và để biết cụ thể ứng dụng tương tác với server thế nào thì mình cần tìm thêm các hàm gọi API.
Trong file .dll vừa mở, mình tiếp tục tìm được một class có tên AccountService mà thông qua tên các hàm xử lý (Register, Login, ResetPassword, GetProfile), mình đoán class này có nhiệm vụ xử lý các tác vụ trên tài khoản như đăng ký, đăng nhập, đặt lại mật khẩu,...
Đây là phần code xử lý của class nói trên:
Từ đây, mình bắt đầu thử tạo ra một request hợp lệ tới server. Ví dụ từ hàm login:
Mình biết được một số thông tin như sau:
- Method của request là POST
- Path là login/
- Request body gồm loginId và password
Cộng với việc biết base API và định dạng dữ liệu JSON, mình xây dựng được một request thế này:
Và qua việc đăng nhập thử mình biết được định dạng request như trên của mình là hợp lệ, và nếu thông tin đăng nhập đúng thì server trả về một số thông tin tài khoản như thế này:
Sau khi biết cách tạo request đến server, mình tiếp tục để ý đến 2 hàm sau:
Nhìn vào từ khóa Topup, mình đoán chức năng của 2 hàm này liên quan tới việc nạp tiền điện thoại.
Hàm đầu tiên mình đoán có tác dụng gọi API để nạp thẻ, với msisdn là số điện thoại cần nạp và voucherCode là mã thẻ, và Settings.CurrentUser.Msisdn là số điện thoại của tài khoản hiện tại (trong trường hợp bạn nạp thẻ cho một tài khoản khác).
Từ đó mình xây dựng được một request như sau:
Với các tham số:
- <current_user_phone_number> là số điện thoại của tài khoản hiện tại
- <voucher_code> là mã thẻ
- <target_phone_number> là số điện thoại cần nạp
Để biết response khi nạp thẻ đúng thì mình đã nạp thử 1 thẻ 20.000đ của nhà mạng này bằng cách gửi request như trên, và tham số trả về chỉ gồm một con số 20000 nên mình đoán đây là giá trị của thẻ nếu nạp thẻ thành công.
Nếu thử nạp lại thẻ đó một lần nữa thì response trả về sẽ là ERROR: VOUCHER_IS_ALREADY_CONSUMED.
Thử với một mã thẻ không tồn tại thì response trả về là ERROR: VOUCHER_PIN_NOT_FOUND_IN_DB
Sau đó mình thử lặp lại request nạp tiền nhiều lần với nhiều mã thẻ random khác nhau, tuy không nạp được thẻ nào nhưng cũng không thấy dấu hiệu bị rate limit. Như vậy hệ thống của Y có khả năng bị bruteforce mã thẻ, bằng cách thử nạp tất cả các mã thẻ có độ dài bằng với độ dài hợp lệ.
Tuy nhiên đó vẫn chưa phải lỗi khiến mình viết ra bài này...
Từ hàm thứ hai:
Ta biết được một số thông tin như sau:
- Method của request là GET
- Request URL có dạng subscriber/etopup/<msisdn>/<amount>, với msisdn, là số điện thoại của tài khoản hiện tại, và một biến <amount>
Dựa vào từ khóa TopUpSuccessfulNotification, mình đoán hàm này dùng để thông báo việc nạp tiền thành công.
Tuy nhiên...
Liệu hàm này là để thông báo việc nạp tiền thành công cho người dùng ứng dụng, hay cho server?
Tại sao một hàm có chức năng thông báo lại sử dụng biến amount? Phải chăng biến này dùng để "thông báo" số tiền cần nạp?
Để giải quyết mối nghi này, mình tạo một request có dạng như sau đến server của Y (mặc dù method ghi là GET nhưng mình dùng POST thấy được nên sau đó không để ý test lại luôn):
Với <phone_number> là số điện thoại của mình, <amount> mình thử con số 1337
Thật kì lạ, server trả về con số 21337, bằng với số tiền trong tài khoản của mình là 20000 cộng thêm con số 1337 ở trên.
Ngay sau đó mình check thử số tiền trong tài khoản bằng cú pháp *101#
Thì thấy số tiền lúc này đã thay đổi đúng như trong response trả về. Sau khi thử đi thử lại vài lần trên các số điện thoại khác nhau, mình nhận thấy rằng có thể dùng API này để nạp tiền vào bất cứ tài khoản nào thuộc nhà mạng Y, chậm chí khi nạp xong còn có tin nhắn quảng cáo của nhà mạng gửi về giống như khi nạp tiền bằng thẻ cào.
Proof of Concept
Phân tích dài vậy, tại sao có thể gọi là hack được của Y "sau một nốt nhạc"?
Sau khi phân tích và lấy được API của Y, những lần sau để nạp tiền mình chỉ cần request đến đúng API đó là được, thậm chí có thể viết script để chỉ cần nhập số điện thoại và số tiền là nạp được. Mình đã demo ngay chính trong PoC ở trên bằng một đoạn script Python.
Lỗ hổng này từ đâu mà có?
Mình đã tìm thử xem hàm gây ra lỗ hổng được sử dụng ở đâu trong code, tuy nhiên tìm không thấy, nên mình đoán đây là phần code đã bị Y "bỏ quên" trong ứng dụng cũng như API trên server.
Chẳng hiểu ai lại đi làm ra cái "tính năng" này, nhưng nếu ít nhất anh ta chịu remove dead code đi thì có lẽ mọi việc đã chả đến nỗi, ít ra thì sẽ không bị một đứa rảnh ruồi nào đó như mình dịch ngược ứng dụng và phát hiện ra chẳng hạn :D.
Timeline
- 2/3/2018: Báo cáo lỗ hổng tới Y
- 1/4/2018: Xác nhận Y đã fix lỗ hổng
P/S: Vì mình không có nhiều hiểu biết về Android cũng như C# nên có chỗ nào sai sót mong được góp ý thêm.