Skip to content

Đục lỗ Liferay deserialization

Ngay cái tiêu đề chắc mọi người cũng đã biết rõ bài này viết về cái gì rồi!
Đây là bài viết sơ lược về 1 lỗi tồn tại khá lâu trong các portal sử dụng nền tảng liferay, nhưng không hiểu sao mà đến thời điểm bọn mình bắt tay vào phân tích và đi khai thác dạo thì khoảng 60% các target sử dụng liferay đều có thể bị khai thác đc ¯\_(ツ)_/¯.
Team mình có chia sẻ bài này sơ qua tại ngày hội ATTT 2018, sau đó có 1 số bên contact hỏi han xin chi tiết về bug này.
Khi đó thì mấy ae còn khá bận việc và các target bọn mình làm chưa có fix đc hết nên bọn mình không thể gửi detail được.
Hôm nay thì vì 1 lý do nào đó (trời ấm lên chăng?!) nên mình quyết định viết và publish về cái bug này!
Ngày 1/12 lên show thì chưa có luật Anima, ko biết giờ này post lên có bị dính vào vòng lao lý ko nữa :<<<<
Nói sơ qua 1 chút về cái bug chết người này:
Đây là 1 bug về deserialization của chức năng TunnelServlet, đoạn code thực hiện deserialize object của nó rất đơn giản:
public void doPost(HttpServletRequest request, HttpServletResponse response)
    throws IOException {

    ObjectInputStream ois;

    try {
      ois = new ObjectInputStream(request.getInputStream());
    }
    catch (IOException ioe) {
      if (_log.isWarnEnabled()) {
        _log.warn(ioe, ioe);
      }

      return;
    }

    Object returnObj = null;

    boolean remoteAccess = AccessControlThreadLocal.isRemoteAccess();

    try {
      //...

      ObjectValuePair<HttpPrincipal, MethodHandler> ovp =
        (ObjectValuePair<HttpPrincipal, MethodHandler>)ois.readObject();

      //...
    }
    catch (InvocationTargetException ite) {
      //....
}
  } 
TunnelServlet nhận ObjectInputStream từ Post data và readObject() ngay phía dưới và tạo ra cái bug deserialize ở đây.
Entrypoint khai thác có thể tìm thấy tại “/api/liferay“, path này được route trực tiếp tới TunnelServlet.
Bug này khai thác khá đơn giản hoy, chỉ cần gen payload bằng ysoserial (lưu ý với 1 số phiên bản liferay khác nhau thì dùng các gadget khá nhau), mình dùng CC5 để test:
exploit sử dụng CC5
Gửi payload qua post
Nhưng có 1 vấn đề khá lớn đó là entrypoint này chỉ mở cho các request từ local, mọi request từ bên ngoài đều bị filter hết!!!
Có lẽ đây chính là lý do làm cho các h4x0r từ bỏ sớm, ko tìm hiểu tiếp về bug deserialize này!
Vì ngay cả với mình cũng vậy, khi gặp bug chưa gì đã restrict for authenticated only, local only thì bỏ sớm là chuyện đương nhiên!
Case 1:
Về vấn đề này, thật may mắn là khi google search mình có tìm đc 1 link của những người đi trước tìm hiểu về nó, cụ thể là tenable! (https://www.tenable.com/security/research/tra-2017-01).
Nội dung bài này nói về blacklist bypass của bug.
Đọc kỹ bài viết đó một chút có 1 dòng như thế này:
Đến chỗ này thì mình cũng đã suy đoán ra 1 số vấn đề ( ͡° ͜ʖ ͡°) ( ͡° ͜ʖ ͡°) :
Trên linux đối với webserver tomcat, khi muốn run với port 80 thì sẽ phải chạy với quyền root.
Vậy có khi nào không có quyền root mà nó vẫn chạy được với port 80 ko?? (nhắc tới đây lại nhớ tới bác này https://www.youtube.com/watch?v=NmHeNGFQrLo )), ko lquan đến bài đâu, mình chỉ seed cho vui hoy).
Câu trả lời là có, nhiều là đằng khác!
Nhưng phương pháp phổ biến thường được các bác deployer sử dụng đó là Proxy/LB:
Các server tomcat sẽ được chạy ở các port từ 1024 trở đi với quyền của user thường, không cần đến quyền root.
Sẽ có 1 con Proxy hoặc LoadBalancer đứng giữa, chạy trên port 80 để forward request qua server tomcat.
Như vậy, giả sử như Proxy và Tomcat server được đặt chung 1 máy chủ thì khi server Tomcat nhận được request, chính là request mà Proxy forward sang, và remoteIp nhận được lúc này CHÍNH LÀ IP CỦA MÁY CHỦ ĐÓ.
Vô tình các request từ internet tới server Tomcat sẽ có ip là ip của máy chủ tomcat.
Để confirm, mình setup thử HAProxy và 1 simple webserver chạy cùng 1 máy.
Rule của HAProxy listen trên port 80 và forward về port 4444:
Khi connect trực tiếp tới port 4444, server sẽ nhận được ip chính xác của remote user (ip remote là 192.168.75.135, ip web server là 192.168.75.138)
Còn với request đến port 80 (đi qua Proxy) thì web server sẽ nhận ip là ip của proxy:
.
.
=> Chức năng filter lúc này trở nên vô dụng vì các rule của liferay hầu hết đều accept truy cập tới từ ip của chính nó (maybe: 192.168.x.x, còn 127.0.0.1 thì đương nhiên cũng được accept rồi).
PoC:
  • Denied khi truy cập trực tiếp thông qua port 8080:
  • Attack thành công với request thông qua HAProxy:
Case 2:
Tomcat không filter được, vậy bây giờ muốn fix phải làm sao??
“Đơn giản thôi, thêm rule filter vào acl của HAProxy là đc?!”
Sau khi bọn mình gởi report tới 1 bên thì họ có thực hiện ngay việc chặn cái entrypoint /api/liferay này.
Rule của HAProxy có dạng như sau:
acl restrict_liferay path_beg,url_dec -i /api/liferay 
Restart HAProxy xong thì đúng là truy cập tới /api/liferay bị chặn thật:
.
.
What next…
Gần như đã hết cách để tiếp tục access lỗi deserialize kia thì đồng nghiệp của mình có nghĩ ra 1 cách để bypass ACL kia,
:vv Truy cập tới “/api/liferay” bị chặn, nhưng TRUY CẬP TỚI “/api///////liferay” thì không bị chặn…
)) Khi biết được thông tin này mình cũng ko tin lắm, nhưng thực sự thì … it works!!
Chỉ cần thêm 1 đống // vào thì rule của HAProxy trở nên vô nghĩa.
Dựa vào đó mà đồng nghiệp mình lại exploit thành công với 1 đống site khác có cùng kiểu config như trên )).
Thực sự thì mình ko biết đây là 1 bug hay 1 feature của HAProxy nữa …
Bởi vì khi đem cách bypass trên áp dụng vào các target cũ thì đúng là có thể pass ACL 1 cách ez :vvv.
Nếu hệ thống của bạn quản trị có sử dụng HAProxy thì mình suggest nên kiểm tra lại xem điều mình làm bên trên có đúng hay ko.
Tết nhất tới nơi rồi, đề phòng củi lửa :Vvv.
Case 3:
:Vv Sau khi đem cái case 2 kia đi ốp vào thực tế thì gặp khá nhiều trường hợp quái lạ, và đó là nguyên nhân dẫn đến cái case 3 này :vv.
Case 3 này có cùng 1 cách khai thác như case 2, cùng là sử dụng “/api////liferay” để bypass filter check, nhưng chỉ có điều root cause lại ở 1 chỗ khác )).
Trong những case đồng nghiệp có làm bounty, thì có rất nhiều trường hợp không sử dụng Proxy gì cả, nhưng vẫn bypass thành công … Lúc đó thì vẫn chưa tìm hiểu rõ lắm, đến tận ngày show ở Ngày an toàn thông tin bọn mình vẫn chưa hiểu trường hợp đó là do đâu.
Chỉ đến vài ngày trong tuần này có rảnh rỗi, mình ngồi tìm hiểu cách setup và debug 1 con liferay servlet thì mới biết rõ nguyên do! (Việc tìm bug chỉ mất 1 tiếng, còn setup mất 3 ngày để setup :<<<, suck maven, suck gradle …).
Bug của case 3 này là bug xảy ra bên trong code của liferay servlet, và được fix từ các bản 6.2.3-ga4 trở lên,
Còn info cụ thể của bug thì mình không tìm được 1 info gì cả, chỉ khi soi code mới thấy có sự thay đổi để fix bug này!
.
.
Mình chọn liferay version 6.1.0 để thực hiện analyze bug này.
Sau khi trace mình có thấy được class “com.liferay.portal.kernel.servlet.filters.invoker.InvokerFilter” được gọi để thực hiện filter các request tới, ở method doFilter như sau:
Và cứ lần theo đó để trace tiếp thì tới được nơi đẩy ra request 403 block khi truy cập từ ip bị chặn:
Traceflow của mình như sau:
Sau khi trace 1 hồi mình có để ý là đối với các request “/api/liferay” đều có “SecureFilter” trong filterList (các filter rule được lưu vào 1 list object và gọi tới khi filter).
Nhưng đối với các request bypass “/api////liferay” đều không có rule này trong list!
Vậy thì vấn đề mấu chốt ở đây đó là vì 1 lý do gì đó mà SecureFilter đã không được thêm vào list của filter rule khi truy cập tới path trên!
Quay lại file InvokerFilter và xem xét, vì các filter sẽ được setup ở đây, nếu có sự khác nhau thì cũng chính xác là từ đây xảy ra!
Ở đây method getInvokerFilterChain() sẽ được gọi để thiết lập filterChain cho url (cũng chính nơi đây filter List được thiết lập).
Trace tiếp tục vào method getInvokerFilterChain(),
Method sẽ lấy filterChain từ 1 list có trước (biến _filterChains), với key ở đây chính là hashcode của uri truyền vào!!!
Trong trường hợp mình truyền vào “/api////liferay” (nghĩa là nhiều dấu /// đó), thì sẽ tạo ra 1 hashcode khác, chưa từng tồn tại trong _filterChains, khi đó invokerFilterChain == null, và sẽ nhảy tiếp vào statement phía dưới.
_invokerFilterHelper.createInvokerFilterChain() tạo ra 1 object filterChain mới.
Method này khởi tạo object filterChain mới, sau đó check match của URI truyền vào với các filterMap đã có sẵn (các rule define trong config của file xml).
Tiếp tục trace vào method check match này, chức năng của method isMatch() này là check xem URI truyền vào có giống với pattern của rule không, nếu có thì sẽ trả về true và add filter này vào list, flow của code hiện tại như sau:
Sau khi F8 một hồi, và cuối cùng cũng đã tới url pattern là “/api/liferay/*”:
)) Và dĩ nhiên, nhìn qua mắt thường ta cũng có thể biết được uri truyền vào và url pattern kia không hề match!!!
matchURLPattern trả về false
=> Đây là lý do mà SecureFilter phía trên không được add vào với request là “/api////liferay”!
=> SecureFilter không được invoke nên bị bypass!
Exploit lại 1 lần nữa thành công, với PoC y chang như case 2 :Vvv :
Chốt lại, root cause của bug trong case 3 này là do InvokerFilter.getURI() xử lý không tốt, nên URI trả về chưa được tối ưu, các rule được check sẽ không còn chuẩn nữa!
Từ version 6.2.3-ga4 trở đi, đã có thêm method normalizepath được add vào cuối method getURI() để tối ưu path lại, get uri chuẩn hơn:
Và từ đó không ai còn thấy bug này xảy ra nữa ¯\_(ツ)_/¯
.
.
Trên đây là toàn bộ những gì mình muốn chia sẻ về bug deserialize của liferay và cách bypass mình có sử dụng.
Hy vọng sẽ phần nào đó giúp 1 số đơn vị biết sợ và chăm chỉ update patch thường xuyên, tránh những trường hợp không mong muốn có thể xảy ra ¯\_(ツ)_/¯ .
Chúc các bạn một ngày tốt lành!
Thanks Pham Pham && Lê Thanh Lĩnh for great works!
__Jang from VNPT ISC__
P/s: Join us, we have much more than this
Jang – Trung tâm An toàn thông tin
1,134 lượt xem