Last spring I did my master research project at EPFL’s SYSTEMF lab. The context: a web platform hosting around a hundred student-developed Scala apps, all running inside a single JVM process on one machine. No isolation between apps. No visibility into what each app actually does at runtime. And the server sitting inside EPFL’s internal network is a decent pivot point if someone wanted to go further.

The goal was to change that.

The problem with shared JVM hosting

When you run a hundred apps in one JVM, you’re essentially trusting every student not to do anything stupid or malicious. Reflection lets you manipulate other apps’ behavior at runtime. The Java standard library gives you filesystem and network access. Serialization is a classic exploit vector. And if one app achieves arbitrary code execution, the whole machine is yours.

The threat model had two categories: malicious students with legitimate deploy access, and external attackers exploiting vulnerabilities introduced by inexperienced developers. Both land on the same host.

Static bytecode analysis

The first layer was a bytecode analyzer built with the ASM framework. Instead of running code to observe behavior, ASM lets you inspect JVM bytecode directly — closer to execution than source analysis, without actually executing anything.

The analyzer flags unsafe operations: filesystem access, network calls, JNI invocations (native code that bypasses Java security entirely), and serialization. The idea is to catch obviously dangerous patterns before they ever reach production.

The limitation of static analysis is reflection, you can’t know at analysis time what a reflective call will resolve to at runtime. That’s where GraalVM comes in.

GraalVM native image and the closed-world assumption

GraalVM’s native image compiler works under a closed-world assumption: everything the application needs must be known at build time. Reflection has to be explicitly registered. Dynamic class loading disappears. The result is a native binary with a dramatically reduced runtime attack surface.

The numbers from our build: we went from 22,793 types in the JAR to 20,843 in the native image. More importantly, reflective access was constrained to an explicit whitelist (4,216 types vs. everything), and JNI exposure dropped to 71 types with 4 native libraries.

To make this work without breaking app loading, I wrote a bytecode analysis pass that walks the class hierarchy, identifies all instantiable webapp classes, and generates compile-time instantiation code. No more runtime reflection for app loading 100% accuracy, zero false positives.

The tradeoff: a three-stage build pipeline (compile JAR → analyze → regenerate JAR with static loaders → native image), and manual GraalVM reflection metadata generation that takes 3–5 minutes per build. Not ideal, but workable.

Container hardening

The deployment runs in a Podman rootless pod , no capabilities, privilege escalation disabled. Two containers: the webapp and an Nginx reverse proxy. The webapp container has a completely read-only filesystem. Nginx uses targeted tmpfs mounts only for the directories it actually needs at runtime.

Base images are Chainguard distroless with no shell, no package manager, zero known CVEs at deployment time. If someone escapes the JVM sandbox, there’s not much left to work with.

Network-wise, the webapp has no published ports. All external traffic goes through Nginx, which does SSL termination, rate limiting, and security headers before forwarding anything to the app.

Server hardening and CI

On the host: unused processes removed, ports reviewed, Fail2ban for SSH brute force, and Linux auditd watching critical files (/etc/passwd, /etc/shadow, /etc/sudoers) and all execve calls.

The whole build, validation, and deployment pipeline runs through GitLab CI with Ansible playbooks. The SSH deployment key only exists inside the CI environment — you can’t push to production without going through the pipeline and code review.

OpenSCAP evaluation against Chainguard GPOS STIG profiles gave 88.89% compliance (86/91 rules passed). The 5 failures are all cryptographic, FIPS-validated crypto at the OS level — which is intentional: cryptographic controls are delegated to the host and Java runtime, not the container image.

What’s missing

A few things I’d do differently or add next:

  • Seccomp profiles. Neither container has one. The kernel attack surface is broader than it needs to be.
  • Unix socket communication. Webapp and Nginx talk over a network interface right now. A Unix socket would be cleaner and tighter.
  • Runtime monitoring. Static analysis catches known patterns at compile time. It says nothing about novel behavior during execution. A lightweight anomaly detection layer would add a meaningful second layer.
  • Automated reflection metadata. The manual GraalVM config step is the biggest friction point in the pipeline. Automating it would remove the last significant human error vector.

The project report is available here. +++