近期,研究人员报告了一个存在于Apache OFBiz中的反序列化漏洞。这个漏洞是由多个Java反序列化问题所导致的,当代码在处理发送至/webtools/control/xmlrpc的请求时,便有可能触发该漏洞。未经认证的远程攻击者将能够通过发送精心构造的恶意请求来触发并利用该漏洞,并实现任意代码执行。
Apache OFBiz是一个开源的企业资源规划(ERP)系统,它提供了一系列企业应用程序来帮助企业自动化实现很多业务流程。它包含了一个能提供常见数据模型和业务进程的框架,企业内所有的应用程序都需要采用这个框架来使用常见数据、逻辑和业务处理组件。除了框架本身之外,Apache OFBiz还提供了包括会计(合同协议、票据、供应商管理、总账)、资产维护、项目分类、产品管理、设备管理、仓库管理系统(WMS)、制造执行/制造运营管理(MES/MOM)和订单处理等功能,除此之外,还实现了库存管理、自动库存补充、内容管理系统(CMS)、人力资源(HR)、人员和团队管理、项目管理、销售人员自动化、工作量管理、电子销售点(ePOS)、电子商务(电子商务)和scrum(开发)等多种功能。
Apache OFBiz使用了一系列开源技术和标准,比如Java、JavaEE、XML和SOAP。
超文本传输协议是一种请求/响应协议,该协议在 RFC 7230-7237中有详细描述。请求由客户端设备发送至服务器,服务器接收并处理请求后,会将响应发送回客户端。一个HTTP请求由请求内容、各种Header、空行和可选消息体组成:
Request = Request-Line headers CRLF [message-body]
Request-Line = Method SP Request-URI SP HTTP-Version CRLF
Headers = *[Header]
Header = Field-Name “:” Field-Value CRLF
CRLF代表新的行序列回车符(CR),后跟换行符(LF),SP表示空格字符。参数将以键值对的形式通过Request- URI或message-body由客户端传递给服务器,具体将取决于Method和Content-Type头中定义的参数。比如说在下面的HTTP请求样本中,有一个名为“param”的参数,其值为“1”,使用的是POST方法:
POST /my_webapp/mypage.htm HTTP/1.1
Host: www.myhost.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 7
param=1
Java支持对对象进行序列化操作,使它们额能够被表示为紧凑和可移植的字节流,然后可以通过网络传输这个字节流,并将其反序列化以供接收的servlet或applet使用。下面的示例演示了如何将一个类进行序列化并在随后提取数据:
public static void main(String args[]) throws Exception{
//This is the object we're going to serialize.
MyObject1 myObj = new MyObject1();
MyObject2 myObj2 = new MyObject2();
myObj2.name = "calc";
myObj.test = myObj2;
//We'll write the serialized data to a file "object.ser"
FileOutputStream fos = new FileOutputStream("object.ser");
ObjectOutputStream os = new ObjectOutputStream(fos);
os.writeObject(myObj);
os.close();
//Read the serialized data back in from the file "object.ser"
FileInputStream fis = new FileInputStream("object.ser");
ObjectInputStream ois = new ObjectInputStream(fis);
//Read the object from the data stream, and convert it back to a String
MyObject1 objectFromDisk = (MyObject1)ois.readObject();
ois.close();
}
所有的Java对象都需要通过Serializable或Externalizable接口来进行序列化,这个接口实现了writeObject()/writeExternal()和readObject()/readExternal()方法,它们会在对象序列化或反序列化时被调用。这些方法能够在序列化和反序列化过程中通过修改代码来实现自定义行为。
XML-RPC是一个远程过程调用(RPC)协议,它使用XML对其调用进行编码,并使用HTTP作为传输机制。它是一种标准规范,并提供了现成的实现方式,允许运行在不同的操作系统和环境中。在在XML-RPC中,客户机通过向实现XML-RPC并接收HTTP响应的服务器发送HTTP请求来执行RPC。
每个XML-RPC请求都以XML元素“”开头。此元素包含一个子元素“something”。元素“”包含子元素“”,该子元素可以包含一个或多个“”元素。param XML元素可以包含许多数据类型。
如下样例所示,常见的数据类型可以被转换成对应的XML类型:
<array>
<data>
<value><i4>1404</i4></value>
<value><string>Something here</string></value>
<value><i4>1</i4></value>
</data>
</array>
各种原语的编码示例如下:
<boolean>1</boolean>
<double>-12.53</double>
<int>42</int>
字符串的编码示例如下:
<string>Hello world!</string>
对结构体的编码示例如下:
<struct>
<member>
<name>foo</name>
<value><i4>1</i4></value>
</member>
<member>
<name>bar</name>
<value><i4>2</i4></value>
</member>
</struct>
序列化数据由””和””XML元素包裹来表示,在Apache OFBiz中,序列化代码在org.apache.xmlrpc.parser.SerializableParser这个Java类中实现。
但是,Apache OFBiz中存在一个不安全的反序列化漏洞,这个漏洞是由于OFBiz被配置为在发送到“/webtools/control/xmlrpc”URL时使用XML-RPC拦截和转换HTTP主体中的XML数据所导致的。发送到此端点的请求最初由org.apache.ofbiz.webapp.control.RequestHandler这个Java类来处理,它确定的URL的映射方式。接下来,org.apache.ofbiz.webapp.event.XmlRpcEventHandler类将调用execute()方法,XML解析首先需要通过XMLReader类来调用parse()方法,而这个方法需要在org.apache.ofbiz.webapp.event.XmlRpcEventHandler类的getRequest()方法中调用。
XML-RPC请求中的元素将会在下列类中被解析:
org.apache.xmlrpc.parser.XmlRpcRequestParser
org.apache.xmlrpc.parser.RecursiveTypeParserImpl
org.apache.xmlrpc.parser.MapParser
不安全的序列化问题存在于org.apache.xmlrpc.parser.SerializableParser类的getResult()方法之中。一个未经身份验证的远程攻击者可以利用该漏洞来发送包含了定制XML Payload的恶意HTTP请求。由于OFBiz使用了存在漏洞的Apache Commons BeanUtils库和Apache ROME库,攻击者将能够使用ysoserial工具以XML格式来构建恶意Payload。该漏洞的成功利用将导致攻击者在目标应用程序中实现任意代码执行。
下列代码段取自Apache OFBiz v17.12.03版本,并添加了相应的注释。
public void doRequest(HttpServletRequest request, HttpServletResponse response, String chain,
GenericValue userLogin, Delegator delegator) throws RequestHandlerException,
RequestHandlerExceptionAllowExternalRequests {
ConfigXMLReader.RequestResponse eventReturnBasedRequestResponse;
if (!this.hostHeadersAllowed.contains(request.getServerName())) {
Debug.logError("Domain " + request.getServerName() + " not accepted to prevent host header injection ", module);
throw new RequestHandlerException("Domain " + request.getServerName() + " not accepted to prevent host header injection ");
}
boolean throwRequestHandlerExceptionOnMissingLocalRequest = EntityUtilProperties.propertyValueEqualsIgnoreCase("requestHandler", "throwRequestHandlerExceptionOnMissingLocalRequest", "Y", delegator);
long startTime = System.currentTimeMillis();
HttpSession session = request.getSession();
ConfigXMLReader.ControllerConfig controllerConfig = getControllerConfig();
Map<String, ConfigXMLReader.RequestMap> requestMapMap = null;
String statusCodeString = null;
try {
requestMapMap = controllerConfig.getRequestMapMap();
statusCodeString = controllerConfig.getStatusCode();
} catch (WebAppConfigurationException e) {
Debug.logError((Throwable)e, "Exception thrown while parsing controller.xml file: ", module);
throw new RequestHandlerException(e);
}
if (UtilValidate.isEmpty(statusCodeString))
statusCodeString = this.defaultStatusCodeString;
String cname = UtilHttp.getApplicationName(request);
String defaultRequestUri = getRequestUri(request.getPathInfo());
if (request.getAttribute("targetRequestUri") == null)
if (request.getSession().getAttribute("_PREVIOUS_REQUEST_") != null) {
request.setAttribute("targetRequestUri", request.getSession().getAttribute("_PREVIOUS_REQUEST_"));
} else {
request.setAttribute("targetRequestUri", "/" + defaultRequestUri);
}
String overrideViewUri = getOverrideViewUri(request.getPathInfo());
String requestMissingErrorMessage = "Unknown request [" + defaultRequestUri + "]; this request does not exist or cannot be called directly.";
ConfigXMLReader.RequestMap requestMap = null;
if (defaultRequestUri != null)
//get the mapping for the URI
requestMap = requestMapMap.get(defaultRequestUri);
if (requestMap == null) {
String defaultRequest;
//[...truncated for readability.....]
ConfigXMLReader.RequestResponse nextRequestResponse = null;
if (eventReturn == null && requestMap.event != null
&& requestMap.event.type != null
&& requestMap.event.path != null
&& requestMap.event.invoke != null)
try {
long eventStartTime = System.currentTimeMillis();
//call XmlRpcEventHandler
eventReturn = runEvent(request, response, requestMap.event, requestMap, "request");
public void execute(XmlRpcStreamRequestConfig pConfig, ServerStreamConnection pConnection) throws XmlRpcException {
try {
ByteArrayOutputStream baos;
OutputStream initialStream;
Object result = null;
boolean foundError = false;
try (InputStream istream = getInputStream(pConfig, pConnection)) {
XmlRpcRequest request = getRequest(pConfig, istream);
result = execute(request);
} catch (Exception e) {
Debug.logError(e, module);
foundError = true;
}
if (isContentLengthRequired(pConfig)) {
baos = new ByteArrayOutputStream();
initialStream = baos;
} else {
baos = null;
initialStream = pConnection.newOutputStream();
}
try (OutputStream ostream = getOutputStream(pConnection, pConfig, initialStream)) {
if (!foundError) {
writeResponse(pConfig, ostream, result);
} else {
writeError(pConfig, ostream, new Exception("Failed to read XML-RPC request. Please check logs for more information"));
}
}
if (baos != null)
try (OutputStream dest = getOutputStream(pConfig, pConnection, baos.size())) {
baos.writeTo(dest);
}
pConnection.close();
pConnection = null;
} catch (IOException e) {
throw new XmlRpcException("I/O error while processing request: " + e.getMessage(), e);
} finally {
if (pConnection != null)
try {
pConnection.close();
} catch (IOException e) {
Debug.logError(e, "Unable to close stream connection");
}
}
}
protected XmlRpcRequest getRequest(final XmlRpcStreamRequestConfig pConfig, InputStream pStream) throws XmlRpcException {
final XmlRpcRequestParser parser =
new XmlRpcRequestParser((XmlRpcStreamConfig)pConfig, getTypeFactory());
XMLReader xr = SAXParsers.newXMLReader();
xr.setContentHandler((ContentHandler)parser);
try {
xr.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
xr.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
xr.setFeature("http://xml.org/sax/features/external-general-entities", false);
xr.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
//the parsing of XML in the HTTP body starts in this function
xr.parse(new InputSource(pStream));
//truncated
}
}
public void endElement(String pURI, String pLocalName, String pQName) throws SAXException {
//XML-RPC parsing happens here
switch(--level) {
case 0:
break;
case 1:
if (inMethodName) {
if ("".equals(pURI) && "methodName".equals(pLocalName)) {
if (methodName == null) {
methodName = "";
}
} else {
throw new SAXParseException("Expected /methodName, got " + new QName(pURI, pLocalName), getDocumentLocator());
}
inMethodName = false;
} else if (!"".equals(pURI) || !"params".equals(pLocalName)) {
throw new SAXParseException("Expected /params, got " + new QName(pURI, pLocalName), getDocumentLocator());
}
break;
case 2:
if (!"".equals(pURI) || !"param".equals(pLocalName)) {
throw new SAXParseException("Expected /param, got " + new QName(pURI, pLocalName), getDocumentLocator());
}
break;
case 3:
if (!"".equals(pURI) || !"value".equals(pLocalName)) {
throw new SAXParseException("Expected /value, got " + new QName(pURI, pLocalName), getDocumentLocator());
}
endValueTag();
break;
default:
super.endElement(pURI, pLocalName, pQName);
break;
}
}
public class SerializableParser extends ByteArrayParser {
public Object getResult() throws XmlRpcException {
try {
byte[] res = (byte[]) super.getResult();
ByteArrayInputStream bais = new ByteArrayInputStream(res);
ObjectInputStream ois = new ObjectInputStream(bais);
//insecure deserialization happens here
return ois.readObject();
} catch (IOException e) {
throw new XmlRpcException("Failed to read result object: " + e.getMessage(), e);
} catch (ClassNotFoundException e) {
throw new XmlRpcException("Failed to load class for result object: " + e.getMessage(), e);
}
}
}
为了触发该漏洞,攻击者需要以XML格式在HTTP请求中携带定制的序列化对象,并发送给存在漏洞的目标应用程序,当服务器端在序列化XML数据时,便会触发该漏洞。