GraphNode
Back to all guides
SAST

Java Static Analysis: Common Vulnerability Patterns and How to Catch Them

| 10 min read |GraphNode Research

On March 7, 2017, the Apache Software Foundation published an advisory for CVE-2017-5638, a remote code execution vulnerability in the Jakarta Multipart parser of Apache Struts 2. The bug let an unauthenticated attacker send a crafted Content-Type header containing an OGNL expression that the parser would evaluate during error handling, executing arbitrary commands as the application user. Within seventy-two hours, exploit modules were circulating publicly. Equifax, running an internet-facing customer-dispute portal on a vulnerable Struts version, did not patch in time. Attackers found the unpatched endpoint and over the next seventy-six days exfiltrated personal data on roughly 147 million people. The breach cost more than 1.4 billion USD in settlements and remediation, and it remains the canonical example of what happens when a single Java vulnerability class meets a missed patch window. The CVE itself was a textbook taint flow: an HTTP header reached an expression evaluator without sanitization. Modern Java SAST catches that pattern in seconds.

Java has been the lingua franca of enterprise software for nearly three decades, and the ecosystem has accumulated an unusually deep catalog of vulnerability patterns specific to its libraries, idioms, and runtime behaviors. This guide walks through the Java vulnerability landscape static analysis is built to surface, shows vulnerable-to-fixed code transformations developers actually ship, and explains where Java static code analysis earns its keep. If you are evaluating a static code analysis tool for Java, the patterns below are the floor — any serious Java SAST engine should catch them on the diff that introduces them.

The Java Vulnerability Landscape

A handful of bug classes account for the majority of high-severity findings on Java codebases. Insecure deserialization sits at the top because the JVM's ObjectInputStream reconstructs arbitrary class graphs from untrusted byte streams, and gadget chains in popular libraries turn that primitive into remote code execution. CVE-2015-7501 against Apache Commons Collections demonstrated the pattern publicly, and CWE-502 has been the formal classifier ever since; every JBoss, WebLogic, and Jenkins RCE in the years that followed traces to the same root cause. SQL injection through JDBC remains common because Statement.executeQuery with string concatenation still appears in tutorials a decade after PreparedStatement became the obvious right answer. XSS in JSP and Thymeleaf views shows up wherever a developer escaped the body but forgot attribute context, or used th:utext to render HTML originating from a request parameter.

Past the top three, Java's surface area is wide. XXE in DocumentBuilderFactory and SAXParserFactory persists across SOAP stacks and document-upload features. Command injection through Runtime.getRuntime().exec(String) reaches shell metacharacter parsing because the single-string form invokes StringTokenizer. Path traversal in java.io.File and java.nio.Paths.get appears in any feature that builds a filesystem path from a request value. Hardcoded credentials in application.properties and JDBC URLs remain a perennial finding. Weak cryptography — MessageDigest.getInstance("MD5"), SHA-1, DES, AES in ECB mode, hardcoded IVs — survives in legacy code paths nobody has revisited. Spring adds another layer: CVE-2022-22965 (Spring4Shell) abused class-loader access through Spring's data binder under JDK 9+ on Tomcat, turning an HTTP parameter into RCE through a property-binding chain nobody had thought of as a sink.

SQL Injection: The Pattern Every Java Codebase Still Ships

The vulnerable shape is short enough to memorize, which is part of the reason it survives. A request parameter flows into a Statement through string concatenation, and the database driver executes whatever the attacker supplied:

// VULNERABLE
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;

public ResultSet findUser(Connection conn, String username) throws Exception {
    Statement stmt = conn.createStatement();
    String sql = "SELECT id, email FROM users WHERE username = '" + username + "'";
    return stmt.executeQuery(sql);
}

Pass username as ' OR '1'='1 and the query returns every row in the table; pass it as '; DROP TABLE users; -- on a driver that allows stacked statements and the table is gone. The fix is to bind the value as a parameter, which keeps the query plan and the data on opposite sides of the parser:

// FIXED
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;

public ResultSet findUser(Connection conn, String username) throws Exception {
    String sql = "SELECT id, email FROM users WHERE username = ?";
    PreparedStatement ps = conn.prepareStatement(sql);
    ps.setString(1, username);
    return ps.executeQuery();
}

PreparedStatement sends the SQL template to the database first, then binds the parameter as a typed value the parser never re-interprets as syntax. The same discipline applies to JPA's EntityManager.createQuery with positional or named parameters, to Spring Data's @Query annotations, and to MyBatis' #{} placeholders versus the dangerous ${} form. SAST data flow analysis traces the request value from the controller method parameter through any number of intermediate methods into the executeQuery sink, flagging the path the moment string concatenation breaks the parameterization. See the A03 Injection guide for the broader taxonomy that covers SQL, command, LDAP, and expression-language variants.

Insecure Deserialization: The RCE Primitive Hiding in ObjectInputStream

Java serialization was designed in the 1990s as a transparent way to move object graphs across JVM boundaries. The design assumed both endpoints were trusted. When one endpoint became an HTTP request body, a session cookie, or a message-queue payload, the trust assumption silently inverted, and the result was a decade of remote code execution CVEs across enterprise middleware. The vulnerable shape is anything that hands attacker bytes to ObjectInputStream.readObject():

// VULNERABLE
import java.io.InputStream;
import java.io.ObjectInputStream;

public Object loadSession(InputStream sessionBytes) throws Exception {
    ObjectInputStream ois = new ObjectInputStream(sessionBytes);
    return ois.readObject();
}

If a vulnerable gadget class — anything from Apache Commons Collections, Spring AOP, Groovy, or a dozen other libraries shipped in a typical enterprise classpath — is reachable, the deserializer triggers a chain of method invocations during reconstruction that ends in Runtime.exec. The fix is not to validate the input or strip dangerous bytes; the fix is to replace the entire mechanism, or to constrain what the deserializer is willing to construct:

// FIXED
import java.io.InputStream;
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;

public Object loadSession(InputStream sessionBytes) throws Exception {
    ObjectInputStream ois = new ObjectInputStream(sessionBytes);
    ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
        "com.example.session.SessionState;!*"
    ));
    return ois.readObject();
}

The JEP 290 filter introduced in JDK 9 (and backported to 8u121) lets you whitelist the exact classes the stream is permitted to instantiate; the trailing !* rejects everything else. For new code, prefer a typed format with no executable semantics — JSON via Jackson with default typing disabled, Protocol Buffers, or Avro — and treat any code path that calls readObject on untrusted bytes as a finding regardless of the filter configuration. SAST recognizes the deserialization sink and the absence of a filter on the same stream object, which is the inter-procedural pattern a regex grep cannot reproduce.

XXE in DocumentBuilder: The Default That Reads Your Filesystem

Java's most-deployed XML parser still ships with defaults that resolve external entities, follow file:// URIs into local disk, and reach http:// URIs into the internal network. The vulnerable construction is the four-line shape every Spring REST endpoint that accepts XML inherits by default:

// VULNERABLE
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.Document;

public Document parse(java.io.InputStream xml) throws Exception {
    DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
    DocumentBuilder db = dbf.newDocumentBuilder();
    return db.parse(xml);
}

The single most important hardening line is dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true), which throws a parser exception the moment the document declares a DOCTYPE and closes the XXE class for any application with no legitimate reason to accept DTDs. Wrap the configuration in a shared factory method, write a unit test that asserts the protections hold, and let SAST gate any new DocumentBuilderFactory.newInstance() that does not route through the factory.

Detection: Where Java SAST Earns Its Keep

Java is, in many ways, the language SAST was built for. Strong static typing, explicit method signatures, and ubiquitous dependency injection give static analyzers an unusually clean substrate for inter-procedural data flow. A modern Java static source code analysis engine builds a call graph across the compiled classpath, identifies sources (HTTP parameters in Spring, JAX-RS, and Servlet handlers; message bodies in JMS and Kafka consumers; fields in any readObject), tracks taint forward through assignments and method calls, and flags any path reaching a sink (JDBC executor, runtime exec, XML parser, deserializer) without traversing a sanitizer. The same engine catches configuration patterns — missing parser flags, MessageDigest.getInstance("MD5"), Cipher.getInstance("AES/ECB/PKCS5Padding"), hardcoded keys — that no taint flow needs to reason about.

The Java SAST market has settled into a small set of mature engines. SpotBugs with the FindSecBugs plugin remains the open-source baseline. Commercial engines — Checkmarx, Fortify, Veracode, GraphNode — add deeper data flow and broader rule coverage, catching tainted values passed through a builder, a service layer, and a repository before they reach the sink. IDE integration matters for adoption: an IntelliJ IDEA plugin that surfaces findings as you type closes the feedback loop tighter than any CI gate, and an Eclipse plugin keeps the older Java IDE in the same workflow. The cheapest moment to fix a Java vulnerability is the keystroke that introduced it; the second cheapest is the pull request that proposes to merge it.

Prevention Checklist for Java Codebases

Six rules close the overwhelming majority of real-world Java vulnerabilities. They assume the team has already wired SAST into the pull-request gate; without that, even the strongest checklist degrades to a wiki page nobody re-reads.

  • Parameterize every database call. Use PreparedStatement, JPA named parameters, MyBatis #{} placeholders, or Spring Data query methods. Forbid Statement.executeQuery with concatenated strings at code review and gate it in CI.
  • Treat ObjectInputStream as a footgun. Replace Java serialization with JSON or Protocol Buffers for new code; for legacy code that cannot migrate, wire a JEP 290 ObjectInputFilter with an explicit allowlist on every stream.
  • Harden every XML parser at construction. Disable DOCTYPE, external entities, and external DTDs on DocumentBuilderFactory, SAXParserFactory, XMLInputFactory, and TransformerFactory. Share the configuration through a single factory class.
  • Replace weak cryptography. Migrate MD5 and SHA-1 to SHA-256 or stronger; replace DES and AES ECB with AES/GCM/NoPadding; never hardcode keys or IVs in source. Surface MessageDigest and Cipher instantiations in code review.
  • Avoid Runtime.exec(String) entirely. Use ProcessBuilder with an explicit argument list, or — better — a Java library that performs the operation without shelling out. The string form invokes a tokenizer that attackers can subvert.
  • Move secrets out of source. Replace hardcoded credentials in application.properties, web.xml, and Java constants with a secret manager (HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager). Pair the migration with a SAST and secret-scanning gate that fails the build on regression.

Where GraphNode SAST Fits

GraphNode SAST ships native Java support as a first-class language alongside twelve others, with deep data flow tracking across method, class, and package boundaries on the patterns this guide describes — taint into JDBC sinks, deserialization without filters, XML parsers without hardening, weak crypto, hardcoded credentials, and the Spring shapes behind Spring4Shell. The IntelliJ IDEA and Eclipse plugins surface findings on the keystroke that introduced them; the CI integration gates the pull request before the build leaves the developer's branch. For a broader landscape view, the SAST Tools Buyer's Guide compares ten platforms.

Closing

Java's vulnerability classes are old, well-documented, and individually preventable with a one-line fix. The reason they continue to ship is structural: codebases are large, the dangerous APIs are ergonomic enough that nobody pauses on them, and the safe alternatives require verbose configuration, a different mental model, or wholesale migration off a legacy mechanism. Static analysis works on Java because the language gives it the call graph, the type information, and the explicit signatures that make taint flow tractable. The teams that stop shipping Equifax-shaped breaches are the ones that move detection upstream into the diff and treat the SAST finding as a blocker rather than a backlog ticket.

GraphNode SAST traces taint flow through Java codebases on the diff that introduced the bug — request a demo.

Request Demo