Defending against Log4J2 Vulnerability CVE-2021-44228

Overview of the issue

Log4J 2 has a Remote Code Execution (RCE) vulnerability that is easily exploitable simply by logging a message with specific content.
Since many systems log to e.g. access logs, it is enough to trigger it by sending a special message - e.g. with a UserAgent HTTP header containing this - this is extremely easy for an attacker and it is vital that systems are updated immediately.

See more information at: https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-44228

Examples of different payloads being used actively: https://blog.cloudflare.com/actual-cve-2021-44228-payloads-captured-in-the-wild/

Update 13/12 2021:

While the issue is mitigated in Apache Log4J2 version 2.15.0 by disabling the feature by default that does not remove the vulnerability itself.
It looks like Apache is working on a new version; 2.16.0 that removes this “feature” completely. See https://issues.apache.org/jira/projects/LOG4J2/issues/LOG4J2-3211

Everyone using Log4J2 should prepare to update to that when released.

Update 14/12 2021:
Log4J2 Version 2.16.0 is now released, removing lookups entirely.

How Ceptor can help

Ceptor fortunately does not use Log4J2, but instead SLF4J/Logback, (and Log4J v1.x if configured to do so) so it is not affected.

You can configure the gateway to detect and reject requests that attempt to exploit this vulnerability in applications behind Ceptor Gateway (see https://ceptor.atlassian.net/wiki/spaces/CEPTOR/pages/852012 )

"Below is an example of 2 Gateway Location’s that detect this and return a 400 Invalid Request HTTP response to the client if any HTTP header value, URL or Request Body contains the string “${jndi”.

You can use these examples as-is or modify them to suit your specific setup - e.g. you can add additional logging, or limit to only look at specific content-types instead of the full request body.

Validating only URL and HTTP header values

In this Location, the action is set to respond, with HTTP error code 400.
In the conditions, a single condition is added that checks if URL or any HTTP header values contain the string “${jndi” - note that it does not look for URL encoded variants, only for plain values - if you wish to also detect and prevent URLEncoded values, you need to decode the strings first.

Full Location JSON:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 { "name": "Detect log4j vulnerability", "description": "Example for detecting attempts to exploit log4j vulnerability", "location.enabled": true, "session.needed": false, "valid.methods": "*", "action": "respond", "response.compress": false, "response.status": 400, "conditions": [{ "type": "script", "deny": false, "lowercase": false, "values": ["%{script}function doCheck() {\r\n var pattern = \"$\"+\"{jndi\";\r\n \r\n var uri = context.httpExchange.getRequestURI();\r\n if (uri.indexOf(pattern) >= 0) {\r\n return true;\r\n }\r\n \r\n var headers = context.httpExchange.getRequestHeaders();\r\n var iterator = headers.iterator();\r\n while(iterator.hasNext()) {\r\n var values = iterator.next();\r\n \r\n var iterator2 = values.iterator();\r\n while(iterator2.hasNext()) {\r\n var value = iterator2.next();\r\n if (value.indexOf(pattern) >= 0)\r\n return true;\r\n }\r\n }\r\n \r\n return false;\r\n}\r\n\r\ndoCheck();"] }], "conditions.type": "and", "url.validator": { "enabled": false, "verify.fullurl": false } }

Readable version of the script:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function doCheck() { var pattern = "$"+"{jndi"; var uri = context.httpExchange.getRequestURI(); if (uri.indexOf(pattern) >= 0) { return true; } var headers = context.httpExchange.getRequestHeaders(); var iterator = headers.iterator(); while(iterator.hasNext()) { var values = iterator.next(); var iterator2 = values.iterator(); while(iterator2.hasNext()) { var value = iterator2.next(); if (value.indexOf(pattern) >= 0) return true; } } return false; } doCheck();

Validating URL, HTTP header values and Request Body

In this Location, no conditions are added, and the action is not set to respond.

Instead, the flag “content.preload” is set to true, meaning the entire request body content is read before the location is processed - this will have a slight performance impact since it requires the entire body to be read before further processing continues, effectively disabling the normal streaming of request body.

Since this location is triggered on every request, we use a Location Script to check for the presence of the “${jndi” string and this script then sends a HTTP 400 response back if the string is detected.
Since the full request body is read when the script executes, it also checks for the strings presence in the request body.

Like above it only detects plain text variants and does not look for URLEncoded (or other types of encoding) values - if you wish to do that, you can modify the script accordingly.

Full Location JSON:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 { "name": "Detect log4j vulnerability including full BODY", "description": "Example for detecting attempts to exploit log4j vulnerability", "location.enabled": true, "session.needed": false, "content.preload": true, "valid.methods": "*", "action": "continue", "response.compress": false, "conditions": [], "url.validator": { "enabled": false, "verify.fullurl": false }, "location.script": "%{script}function doCheck() {\r\n var pattern = \"$\"+\"{jndi\";\r\n \r\n var uri = context.httpExchange.getRequestURI();\r\n if (uri.indexOf(pattern) >= 0) {\r\n return true;\r\n }\r\n \r\n var headers = context.httpExchange.getRequestHeaders();\r\n var iterator = headers.iterator();\r\n while(iterator.hasNext()) {\r\n var values = iterator.next();\r\n \r\n var iterator2 = values.iterator();\r\n while(iterator2.hasNext()) {\r\n var value = iterator2.next();\r\n if (value.indexOf(pattern) >= 0)\r\n return true;\r\n }\r\n }\r\n \r\n var body = context.getRequestBodyAsString();\r\n \r\n if (body !== null && body.indexOf(pattern) >= 0) {\r\n return true;\r\n }\r\n \r\n return false;\r\n}\r\n\r\nif (doCheck()) {\r\n context.respond(400, \"Invalid Request\", \"text/plain\", \"Invalid request\");\r\n}" }

Readable version of the script:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 function doCheck() { var pattern = "$"+"{jndi"; var uri = context.httpExchange.getRequestURI(); if (uri.indexOf(pattern) >= 0) { return true; } var headers = context.httpExchange.getRequestHeaders(); var iterator = headers.iterator(); while(iterator.hasNext()) { var values = iterator.next(); var iterator2 = values.iterator(); while(iterator2.hasNext()) { var value = iterator2.next(); if (value.indexOf(pattern) >= 0) return true; } } var body = context.getRequestBodyAsString(); if (body !== null && body.indexOf(pattern) >= 0) { return true; } return false; } if (doCheck()) { context.respond(400, "Invalid Request", "text/plain", "Invalid request"); }