Sekai 2025 Write Up
Signature
SSL Pinning
before solving this challenge, we have to bypass SSL Pinning, i have bypass via objection
Analysis
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<activity
android:theme="@style/Theme.SekaiBank.NoActionBar"
android:name="com.sekai.bank.MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustPan"
android:preferMinimalPostProcessing="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<activity
android:theme="@style/Theme.SekaiBank.Auth"
android:name="com.sekai.bank.ui.auth.AuthActivity"
android:exported="false"
android:windowSoftInputMode="adjustPan"/>
<activity
android:theme="@style/Theme.SekaiBank.NoActionBar"
android:name="com.sekai.bank.ui.pin.PinActivity"
android:exported="false"
android:windowSoftInputMode="adjustPan"/>
there are 3 activities called MainActivity, AuthActivity, PinActivity and only MainActivity is allowed so i started analyzing the code of MainActivity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
try {
this.tokenManager = SekaiApplication.getInstance().getTokenManager();
if (handlePinSetupFlow()) {
return;
}
} catch (Exception unused) {
Intent intent = (Intent) getIntent().getParcelableExtra("fallback");
if (intent != null) {
startActivity(intent);
finish();
}
}
if (this.uiInitialized) {
return;
}
checkAuthentication();
}
checkAuthentication() method will call if handlePinSetupFlow() will return False
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private void checkAuthentication() {
if (this.isAuthenticating) {
return;
}
this.isAuthenticating = true;
this.tokenManager.isTokenValid().thenAccept(new Consumer() { // from class: com.sekai.bank.MainActivity$$ExternalSyntheticLambda2
@Override // java.util.function.Consumer
public final void accept(Object obj) {
MainActivity.this.m209lambda$checkAuthentication$1$comsekaibankMainActivity((Boolean) obj);
}
}).exceptionally(new Function() { // from class: com.sekai.bank.MainActivity$$ExternalSyntheticLambda3
@Override // java.util.function.Function
public final Object apply(Object obj) {
return MainActivity.this.m210lambda$checkAuthentication$2$comsekaibankMainActivity((Throwable) obj);
}
});
}
the checkAuthentication() method will just call the comsekaibankMainActivity() method
1
2
3
4
5
6
7
public /* synthetic */ void m208lambda$checkAuthentication$0$comsekaibankMainActivity(Boolean bool) {
if (bool.booleanValue()) {
startPinVerification();
} else {
startAuthActivity();
}
}
the comsekaibankMainActivity() method seems to display either the login/register ui or the pin code ui to the user
1
2
3
4
private void verifyPin(String str) {
setLoadingState(true);
this.apiService.verifyPin(new PinRequest(str)).enqueue(new AnonymousClass1());
}
and when the user enters a pin code then submit, the application verifies the code using the verifyPin() method
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@PUT("auth/pin/change")
Call<ApiResponse<Void>> changePin(@Body PinRequest pinRequest);
@GET("user/search/{username}")
Call<ApiResponse<User>> findUserByUsername(@Path("username") String str);
@GET("user/balance")
Call<ApiResponse<BalanceResponse>> getBalance();
@POST("flag")
Call<String> getFlag(@Body FlagRequest flagRequest);
@GET("user/profile")
Call<ApiResponse<User>> getProfile();
@GET("transactions/recent")
Call<ApiResponse<List<Transaction>>> getRecentTransactions();
@GET("transactions/{id}")
Call<ApiResponse<Transaction>> getTransaction(@Path("id") String str);
@GET("transactions")
Call<ApiResponse<List<Transaction>>> getTransactions(@Query("page") int i, @Query("limit") int i2);
@GET("user/profile")
Call<ApiResponse<User>> getUserProfile();
@GET("health")
Call<ApiResponse<HealthResponse>> healthCheck();
@POST("auth/login")
Call<ApiResponse<AuthResponse>> login(@Body LoginRequest loginRequest);
@POST("auth/logout")
Call<ApiResponse<Void>> logout();
@POST("auth/refresh")
Call<ApiResponse<AuthResponse>> refreshToken(@Body RefreshTokenRequest refreshTokenRequest);
@POST("auth/register")
Call<ApiResponse<AuthResponse>> register(@Body RegisterRequest registerRequest);
@POST("transactions/send")
Call<ApiResponse<Transaction>> sendMoney(@Body SendMoneyRequest sendMoneyRequest);
@POST("auth/pin/setup")
Call<ApiResponse<Void>> setupPin(@Body PinRequest pinRequest);
@POST("auth/pin/verify")
Call<ApiResponse<Void>> verifyPin(@Body PinRequest pinRequest);
we can find the apiService: there are a lot of routers like api/auth/login, api/auth/logout and api/flag. it means we have to send a request to api/flag router to get the flag
after i intercepted an http packet then tried tosend a request to /api/flag but the error is displayed with an “invalid signature” so i decided to find out what the x-signature header is about
1
2
3
4
5
6
7
8
9
public Response intercept(Interceptor.Chain chain) throws IOException {
Request request = chain.request();
try {
return chain.proceed(request.newBuilder().header("X-Signature", generateSignature(request)).build());
} catch (Exception e) {
Log.e(ApiClient.TAG, "Failed to generate signature: " + e.getMessage());
return chain.proceed(request);
}
}
when i searched with the jadx, i found the snippet shown above, and i figured out how the x-signature header is generated: via the generateSignature() method
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
34
private String generateSignature(Request request) throws IOException, GeneralSecurityException {
Signature[] signatureArr;
String str = request.method() + "/api".concat(getEndpointPath(request)) + getRequestBodyAsString(request);
SekaiApplication sekaiApplication = SekaiApplication.getInstance();
PackageManager packageManager = sekaiApplication.getPackageManager();
String packageName = sekaiApplication.getPackageName();
try {
if (Build.VERSION.SDK_INT >= 28) {
PackageInfo packageInfo = packageManager.getPackageInfo(packageName, 134217728);
SigningInfo signingInfo = packageInfo.signingInfo;
if (signingInfo != null) {
if (signingInfo.hasMultipleSigners()) {
signatureArr = signingInfo.getApkContentsSigners();
} else {
signatureArr = signingInfo.getSigningCertificateHistory();
}
} else {
signatureArr = packageInfo.signatures;
}
} else {
signatureArr = packageManager.getPackageInfo(packageName, 64).signatures;
}
if (signatureArr != null && signatureArr.length > 0) {
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
for (Signature signature : signatureArr) {
messageDigest.update(signature.toByteArray());
}
return calculateHMAC(str, messageDigest.digest());
}
throw new GeneralSecurityException("No app signature found");
} catch (PackageManager.NameNotFoundException | NoSuchAlgorithmException e) {
throw new GeneralSecurityException("Unable to extract app signature", e);
}
}
generateSignature() generates the x-signature in 2 steps, in two steps. first, it generates a message digest using SHA-256 with signatureArr. second, it creates the x-signature by calling the calculateHMAC() method
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
34
35
Java.perform(function () {
var SigInterceptor = Java.use("com.sekai.bank.network.ApiClient$SignatureInterceptor");
SigInterceptor.generateSignature.overload('okhttp3.Request').implementation = function (req) {
console.log("[*] Hooked generateSignature()");
try {
console.log(req)
var method = req.method();
var endpointPath = this.getEndpointPath(req);
var bodyStr = this.getRequestBodyAsString(req);
var str = method + "/api" + endpointPath + bodyStr;
console.log(" str = " + str);
} catch (e) {
console.log(" [error reconstructing str] " + e);
}
var ret = this.generateSignature(req);
console.log(" Signature = " + ret);
return ret;
};
});
Java.perform(function () {
var Req = Java.use('okhttp3.Request');
Req.method.overload().implementation = function () { return 'POST'; };
var Sig = Java.use('com.sekai.bank.network.ApiClient$SignatureInterceptor');
Sig.getEndpointPath.overload('okhttp3.Request').implementation = function (req) { return '/flag'; };
Sig.getRequestBodyAsString.overload('okhttp3.Request').implementation = function (req) { return '{\"unmask_flag\":true}'; };
console.log('[*] Only values fixed (signature calc). Remember: actual request is unchanged!');
});
so we should hook the three functions called method(), getEndpointPath(), getRequestBodyAsString() to make to always return the values we intended
when i just click any button in this application, x-signature is always printed
then just send a request to /api/flag with the x-signature
Transaction
During CTF
after i solved the signature chall, i wanted to solve this one but i couldn’t solve but actually i clearly approached to the way to solve. anyway i didn’t know or i just didn’t even try to think how to chain or what to chain. first of all, we should have drained admin balance to solve (1M usd)
using the “send money” function, we can transfer money to others. if we click the “schedule for later” button, we can schedule the transfer instead. when i found this logic, i searched for the code snippet that implements it
1
2
3
<receiver
android:name="com.sekai.bank.utils.delayed_transaction.DelayedTransactionReceiver"
android:exported="false"/>
i could find the DelayedTransactionReceiver in Manifest.xml
1
2
3
4
5
@Override // android.content.BroadcastReceiver
public void onReceive(Context context, Intent intent) {
Log.d(TAG, "Alarm triggered - processing delayed transactions");
DelayedTransactionManager.processReadyTransactions(context);
}
the DelayedTransactionReceiver seems to process transactions through the processReadyTransactions() method
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void processReadyTransactions(Context context) {
Log.d(TAG, "Processing ready transactions...");
DelayedTransactionManager delayedTransactionManager = new DelayedTransactionManager(context);
List<DelayedTransaction> readyTransactions = delayedTransactionManager.storage.getReadyTransactions();
if (readyTransactions.isEmpty()) {
Log.d(TAG, "No ready transactions found");
delayedTransactionManager.checkAndStopPeriodicCheckingIfNoTransactions();
} else {
Log.d(TAG, "Found " + readyTransactions.size() + " ready transactions");
Iterator<DelayedTransaction> it = readyTransactions.iterator();
while (it.hasNext()) {
delayedTransactionManager.processTransaction(it.next());
}
}
}
the processReadyTransactions() method will fetch all transactions that are already ready then process them so we can expect that it will be called when a scheduled transaction reaches its scheduled time
1
2
3
4
5
6
7
8
9
10
11
public List<DelayedTransaction> getReadyTransactions() {
List<DelayedTransaction> allTransactions = getAllTransactions();
ArrayList arrayList = new ArrayList();
for (DelayedTransaction delayedTransaction : allTransactions) {
if (delayedTransaction.isReadyToProcess()) {
arrayList.add(delayedTransaction);
}
}
Log.d(TAG, "Found " + arrayList.size() + " ready transactions");
return arrayList;
}
all transactions are fetched via the getReadyTransactions() method which internally calls the getAllTransactions() method
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
public List<DelayedTransaction> getAllTransactions() {
File[] transactionFiles;
ArrayList arrayList = new ArrayList();
if (!this.storageDir.exists() || (transactionFiles = getTransactionFiles()) == null) {
return arrayList;
}
for (File file : transactionFiles) {
DelayedTransaction readTransactionFromFile = readTransactionFromFile(file);
if (readTransactionFromFile != null) {
arrayList.add(readTransactionFromFile);
}
}
Log.d(TAG, "Loaded " + arrayList.size() + " transactions");
return arrayList;
}
private DelayedTransaction readTransactionFromFile(File file) {
try {
FileReader fileReader = new FileReader(file);
try {
DelayedTransaction delayedTransaction = (DelayedTransaction) this.gson.fromJson((Reader) fileReader, DelayedTransaction.class);
fileReader.close();
return delayedTransaction;
} finally {
}
} catch (IOException e) {
Log.e(TAG, "Failed to read file: " + file.getName(), e);
return null;
}
}
the getAllTransactions() method calls the readTransactionFromFile() method to read transactions; therefore, the scheduled transactions are written to a file
1
private static final String FOLDER_NAME = "delayed_transactions";
the files are located under the delayed_transactions folder
when i check the delayed_transactions folder, i could find a json file which keys amount, toUsername, scheduledTime were present
however, i wasn’t able to exploit it since i didn’t know what to do, so i eventually gave up
After CTF
1
2
3
4
5
6
7
8
9
10
<provider
android:name="com.sekai.bank.providers.LogProvider"
android:enabled="true"
android:exported="false"
android:authorities="com.sekai.bank.logprovider"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
there is a LogProvider that is not exported
1
2
3
4
5
6
7
8
9
10
11
@Override // android.content.ContentProvider
public ParcelFileDescriptor openFile(Uri uri, String str) throws FileNotFoundException {
if (uri.toString().contains("..")) {
throw new FileNotFoundException("Invalid path!");
}
File file = new File(getContext().getCacheDir(), uri.getPath());
if (!file.exists()) {
throw new FileNotFoundException("Log doesn't exists!");
}
return ParcelFileDescriptor.open(file, 805306368);
}
the provider exposes the openFile() function, which calls the ParcelFileDescriptor.open() method with the 805306368 flag. this flag corresponds to MODE_READ_WRITE | MODE_CREATE | MODE_TRUNCATE, which means the file can be read and written. as a result, wthe scheduled transaction file can be modified via this logic
however, logProvider is not exported so actually it cannot be called from outside the application
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
public void onCreate(Bundle bundle) {
super.onCreate(bundle);
try {
this.tokenManager = SekaiApplication.getInstance().getTokenManager();
if (handlePinSetupFlow()) {
return;
}
} catch (Exception unused) {
Intent intent = (Intent) getIntent().getParcelableExtra("fallback");
if (intent != null) {
startActivity(intent);
finish();
}
}
if (this.uiInitialized) {
return;
}
checkAuthentication();
}
however when we send the intent as a string via the callback extra, the try statement will fail, and the intent will be executed internally so we will able to call the LogProvider so we should modify the scheduled transaction (amount=1M, toUsername=Solver, scheduledTime=now or later) to solve
Fancy Web (Unsolved)
the fancy web challenge is built on wordpress, with a custom plugin called fancy
fancy plugins
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
34
// Process User Input
try {
if (isset($_POST['generate'])) {
echo "<h2>🎯 User Generated Table</h2>";
// Check if user wants to unserialize existing data
if (!empty($_POST['serialized_data'])) {
echo "<h3>Attempting to unserialize user data...</h3>";
$userBase64Data = trim($_POST['serialized_data']);
echo "<p><strong>Base64 Input Data:</strong> " . htmlspecialchars(substr($userBase64Data, 0, 100)) . "...</p>";
// Decode base64 first
$userSerializedData = base64_decode($userBase64Data, true);
if ($userSerializedData === false) {
echo "<p style='color: red;'><strong>❌ Invalid base64 encoding</strong></p>";
echo "<p>Please provide valid base64 encoded serialized data.</p>";
} else {
echo "<p><strong>Decoded Serialized Data:</strong> " . htmlspecialchars(substr($userSerializedData, 0, 150)) . "...</p>";
// Attempt to unserialize user input
$userTable = @unserialize($userSerializedData);
if ($userTable instanceof SecureTableGenerator) {
echo "<p style='color: green;'><strong>✅ Successfully unserialized user data!</strong></p>";
echo "<p><em>Note: __wakeup() method automatically secured the data</em></p>";
echo $userTable->generateTable();
} else {
echo "<p style='color: red;'><strong>❌ Failed to unserialize data or invalid object type</strong></p>";
echo "<p>Please provide valid SecureTableGenerator serialized data.</p>";
}
}
}
when a user sends a reqeust containing the serialized_data in the body, it is passed to the unserialize() function, during this process, if the object being deserialized defines a __wakeup() method, that method will typically be called automatically
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
34
35
36
37
38
39
40
41
42
43
44
45
/**
* SecureTableGenerator - A serializable class for creating beautiful HTML tables
* Features security measures to prevent common serialization attacks, made with vibe coding.
*/
class SecureTableGenerator
{
private $data;
private $headers;
private $tableClass;
private $allowedTags;
// (...)
public function __wakeup()
{
// Complex validation timestamp check
$wakeupStartTime = microtime(true);
// Security: Check for object injection attacks
$this->validateObjectIntegrity();
// Deep property validation with complex logic
$this->performComplexValidation();
// Multi-layer sanitization process
$this->executeAdvancedSanitization();
// Security rate limiting simulation
$this->implementSecurityThrottling();
// Complex data normalization
$this->normalizeDataStructure();
// Advanced security checks
$this->performAdvancedSecurityChecks();
// Reset security properties with validation
$this->resetSecurityProperties();
// Log wakeup completion time for security monitoring
$wakeupEndTime = microtime(true);
$this->logSecurityEvent($wakeupStartTime, $wakeupEndTime);
}
the securetablegenerator class defines a __wakeup() method which in turn calls several methods such as validateObjectIntegrity, performComplexValidation and others. so when we send the reqeust containing the serialized_data in the body, the __wakeup() method will be called
btw, hint 1 suggests taking a close look at in_array blah blah…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private function resetSecurityProperties()
{
// Validate allowed tags
$safeTags = ['b', 'i', 'strong', 'em', 'u', 'span', 'div', 'p'];
$validatedTags = [];
foreach ($this->allowedTags as $tag) {
if (in_array($tag, $safeTags)) {
$validatedTags[] = $tag;
}
}
$this->allowedTags = $validatedTags ?: ['b', 'i', 'strong', 'em', 'u'];
}
digging deeper, i found the call to in_array() within the resetSecurityProperties() method
in PHP, when an object is used in a string context (such as during comparison or output), its __toString() method is automatically called. According to the official php documentation, if a class is treated as a string, the __toString() method of that class will be called
in this case, $this->allowedTags is an object, and whenever it is handled as a string, the __toString() method is called. this behavior allows us to leverage the __toString() method of specific WordPress classes for exploitation
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
<?php
class Demo {
public function __toString() {
echo "__toString() called!\n";
return "hello";
}
}
$obj = new Demo();
$safeTags = ['b', 'i', 'strong', 'em', 'u', 'span', 'div', 'p'];
if (in_array($obj, $safeTags)) {
echo "Match found!\n";
} else {
echo "No match!\n";
}
/*
__toString() called!
__toString() called!
__toString() called!
__toString() called!
__toString() called!
__toString() called!
__toString() called!
__toString() called!
No match!
*/
i wrote a simple poc code to test whether the __toString() method would be called when the class is treated as a string. and the result confirmed that __toString() is indeed called
but in order to exploit this behavior, we need to find another class within wordPress that actually implements a __toString() method
i found a class that implements a __toString() method in the wordpress folder
WP_HTML_Tag_Processor
1
2
3
public function __toString(): string {
return $this->get_updated_html();
}
most of the __toString() methods i found only perform simple logic, such as returning md5(serialize($this)), without calling any additional methods that could lead to a new execution path. however, in the WP_HTML_Tag_Processor class, the __toString() method calls get_updated_html(.
this means we can use the resetSecurityProperties() method to trigger the __toString() method of the WP_HTML_Tag_Processor class
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public function get_updated_html(): string {
$requires_no_updating = 0 === count( $this->classname_updates ) && 0 === count( $this->lexical_updates );
/*
* When there is nothing more to update and nothing has already been
* updated, return the original document and avoid a string copy.
*/
if ( $requires_no_updating ) {
return $this->html;
}
/*
* Keep track of the position right before the current tag. This will
* be necessary for reparsing the current tag after updating the HTML.
*/
$before_current_tag = $this->token_starts_at ?? 0;
/*
* 1. Apply the enqueued edits and update all the pointers to reflect those changes.
*/
$this->class_name_updates_to_attributes_updates();
$before_current_tag += $this->apply_attributes_updates( $before_current_tag );
/*
* 2. Rewind to before the current tag and reparse to get updated attributes.
*
* At this point the internal cursor points to the end of the tag name.
* Rewind before the tag name starts so that it's as if the cursor didn't
* move; a call to `next_tag()` will reparse the recently-updated attributes
* and additional calls to modify the attributes will apply at this same
* location, but in order to avoid issues with subclasses that might add
* behaviors to `next_tag()`, the internal methods should be called here
* instead.
*
* It's important to note that in this specific place there will be no change
* because the processor was already at a tag when this was called and it's
* rewinding only to the beginning of this very tag before reprocessing it
* and its attributes.
*
* <p>Previous HTML<em>More HTML</em></p>
* ↑ │ back up by the length of the tag name plus the opening <
* └←─┘ back up by strlen("em") + 1 ==> 3
*/
$this->bytes_already_parsed = $before_current_tag;
$this->base_class_next_token();
return $this->html;
}
the get_updated_html() method looks like the snippet shown above, and within it, the class_name_updates_to_attributes_updates() method is called
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private function class_name_updates_to_attributes_updates(): void {
if ( count( $this->classname_updates ) === 0 ) {
return;
}
$existing_class = $this->get_enqueued_attribute_value( 'class' );
if ( null === $existing_class || true === $existing_class ) {
$existing_class = '';
}
if ( false === $existing_class && isset( $this->attributes['class'] ) ) {
$existing_class = substr(
$this->html,
$this->attributes['class']->value_starts_at,
$this->attributes['class']->value_length
);
}
if ( false === $existing_class ) {
$existing_class = '';
}
in the class_name_updates_to_attributes_updates() method, we can see that it checks the class attribute from $this->attributes
to understand the next part, it’s important to note that in php, when an object implements the ArrayAccess interface, using array-like syntax to access its elements will automatically call the offsetGet() method
1
$this->attributes['class']
so the offsetGet() method of $this->attributes[‘class’] will be called
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# WP_Block_List
public function offsetGet( $offset ) {
$block = $this->blocks[ $offset ];
if ( isset( $block ) && is_array( $block ) ) {
$block = new WP_Block( $block, $this->available_context, $this->registry );
$this->blocks[ $offset ] = $block;
}
return $block;
}
# class-wp-hook.php
477,18: public function offsetGet( $offset ) {
public function offsetGet( $offset ) {
return isset( $this->callbacks[ $offset ] ) ? $this->callbacks[ $offset ] : null;
}
# class-wp-theme.php
public function offsetGet( $offset ) {
switch ( $offset ) {
case 'Name':
case 'Title':
/*
* See note above about using translated data. get() is not ideal.
* It is only for backward compatibility. Use display().
*/
return $this->get( 'Name' );
case 'Author':
return $this->display( 'Author' );
case 'Author Name':
return $this->display( 'Author', false );
case 'Author URI':
return $this->display( 'AuthorURI' );
case 'Description':
return $this->display( 'Description' );
case 'Version':
case 'Status':
# jar.php
public function offsetGet($offset) {
if (!isset($this->cookies[$offset])) {
return null;
}
return $this->cookies[$offset];
}
# headers.php
public function offsetGet($offset) {
if (is_string($offset)) {
$offset = strtolower($offset);
}
if (!isset($this->data[$offset])) {
return null;
}
return $this->flatten($this->data[$offset]);
}
# CaseInsensitiveDictionary.php
public function offsetGet($offset) {
if (is_string($offset)) {
$offset = strtolower($offset);
}
if (!isset($this->data[$offset])) {
return null;
}
return $this->data[$offset];
}
after reviewing the offsetGet() implementations across the available classes, none of them provided a suitable gadget for exploitation
WP_Block_List
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* Returns the value by the specified block offset.
*
* @since 5.5.0
*
* @link https://www.php.net/manual/en/arrayaccess.offsetget.php
*
* @param int $offset Offset of block value to retrieve.
* @return WP_Block|null Block value if exists, or null.
*/
#[ReturnTypeWillChange]
public function offsetGet( $offset ) {
$block = $this->blocks[ $offset ];
if ( isset( $block ) && is_array( $block ) ) {
$block = new WP_Block( $block, $this->available_context, $this->registry );
$this->blocks[ $offset ] = $block;
}
return $block;
}
however, inside the offsetGet() method defined in WP_Block_List, i found that it instantiates a WP_Block class
WP_Block
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#[AllowDynamicProperties]
class WP_Block {
// (...)
public function __construct( $block, $available_context = array(), $registry = null ) {
$this->parsed_block = $block;
$this->name = $block['blockName'];
if ( is_null( $registry ) ) {
$registry = WP_Block_Type_Registry::get_instance();
}
$this->registry = $registry;
$this->block_type = $registry->get_registered( $this->name );
$this->available_context = $available_context;
$this->refresh_context_dependents();
}
the WP_Block class is located in the class-wp-block.php file. looking into its __construct() method, we can see that it calls functions like get_instance() and get_registered()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public function get_registered( $pattern_name ) {
if ( ! $this->is_registered( $pattern_name ) ) {
return null;
}
$pattern = $this->registered_patterns[ $pattern_name ];
$content = $this->get_content( $pattern_name );
$pattern['content'] = apply_block_hooks_to_content(
$content,
$pattern,
'insert_hooked_blocks_and_set_ignored_hooked_blocks_metadata'
);
return $pattern;
the get_registered() method, as shown above, internally calls the get_content() method
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private function get_content( $pattern_name, $outside_init_only = false ) {
if ( $outside_init_only ) {
$patterns = &$this->registered_patterns_outside_init;
} else {
$patterns = &$this->registered_patterns;
}
if ( ! isset( $patterns[ $pattern_name ]['content'] ) && isset( $patterns[ $pattern_name ]['filePath'] ) ) {
ob_start();
include $patterns[ $pattern_name ]['filePath'];
$patterns[ $pattern_name ]['content'] = ob_get_clean();
unset( $patterns[ $pattern_name ]['filePath'] );
}
return $patterns[ $pattern_name ]['content'];
}
// class-wp-block-patterns-registry.php
the get_content() method is shown above, and the code passes $patterns[$pattern_name][‘filePath’] directly into an include. this immediately suggests a possible attack vector
1
2
3
4
5
$suspiciousKeywords = [
'eval', 'exec', 'system', 'shell_exec', 'passthru',
'file_get_contents', 'fopen', 'fwrite', 'unlink',
'base64_decode', 'gzinflate', 'str_rot13'
];
Looking at the fancy code, it seems that these values are being filtered, which means we need to bypass the filter in order to exploit it. aside from using tricks like convert.iconv to bypass filtering and achieve rce, the only viable approach is to find a code path where functions such as include, include_once, or file_get_contents are called. the include statement we identified above can serve as the final gadget for this attack
in other words, by crafting a serialized payload that chains together SecureTableGenerator → WP_HTML_Tag_Processor → WP_Block_List → WP_Block in reverse order with carefully aligned arguments, and then base64-decoding it before sending it as the serialized_data body parameter, the challenge can potentially be solved