2025-12-08

Building a RASP: Inside the Application's Mind

RASPPythonSecurityBlue Team

The Limits of the WAF

As security engineers, we love our WAFs (Web Application Firewalls). They sit at the edge, valiantly deflecting SQL injections and XSS payloads before they even touch our servers. But WAFs have a fatal flaw: lack of context.

A WAF sees a request for /etc/passwd. It blocks it. Good. But what if your application needs to read a configuration file that looks like a sensitive path? Or what if the attack is a deserialization exploit that looks like binary garbage to the WAF but triggers a shell execution on the server?

The WAF is a bouncer outside the club. It checks IDs. But once you're inside, if you start breaking bottles, the bouncer can't see you.

Enter RASP (Runtime Application Self-Protection). The security guard inside the club.

What is RASP?

RASP instruments the application itself. It sits inside the runtime environment (like the JVM or Python interpreter) and hooks into critical calls. It doesn't just see "Input: ' OR 1=1 --"; it sees "SQL Query: SELECT * FROM users WHERE id = '' OR 1=1 --".

The Magic: Monkey Patching

Python is dynamic. This makes it dangerous, but also incredibly hackable. We can replace core system functions with our own "hooked" versions at runtime. This is called Monkey Patching.

In agents/python/hooks.py, we swap out the built-in open function:

_original_open = builtins.open

def hooked_open(file, mode='r', ...):
    # 1. Identify who is calling
    caller = get_caller_context()
    
    # 2. Check Policy
    if not policy_engine.check("file_read", file, caller):
        raise PermissionError(f"RASP: Access to {file} denied!")

    # 3. Call original
    return _original_open(file, mode, ...)

builtins.open = hooked_open

Now, every single time the application tries to open a file-whether it's your code, a library, or a hacker's webshell-it has to go through hooked_open.

Context is King

The superpower of RASP is knowing who made the call. If config.py opens /etc/app_config.yaml, that's fine. If user_input.py tries to open /etc/app_config.yaml, that's suspicious.

I implemented a context inspector that walks the stack trace to find the calling module and function:

def get_caller_context():
    frame = inspect.currentframe()
    while frame:
        # Skip our own agent code
        if "agents/python" not in frame.f_code.co_filename:
            return f"{frame.f_code.co_filename}::{frame.f_code.co_name}"
        frame = frame.f_back

This allows us to write granular policies like:

rules:
  "app.py::load_config":
    files:
      read: ["/etc/config.yaml"]
  "app.py::handle_request":
    files:
      read: [] # No file access allowed!

Visualizing the Defense

Here is how the RASP handles legitimate traffic versus an attack in real-time:

sequenceDiagram
    participant User as User/Attacker
    participant App as Application
    participant RASP as RASP Agent
    participant OS as OS Kernel

    Note over User, OS: Normal Request (Whitelisted)
    User->>App: GET /profile?id=123
    App->>RASP: open("user_123.json")
    RASP->>RASP: Check Policy (Caller: views.py::get_profile)
    Note right of RASP: Rule Found: ALLOW
    RASP->>OS: open("user_123.json")
    OS-->>App: File Handle
    App-->>User: 200 OK

    Note over User, OS: Exploit Request (Unknown/Blocked)
    User->>App: GET /profile?id=../../etc/passwd
    App->>RASP: open("/etc/passwd")
    RASP->>RASP: Check Policy (Caller: views.py::get_profile)
    Note right of RASP: No Rule Found: BLOCK
    RASP--xApp: Raise PermissionError
    App-->>User: 500 Internal Server Error

The "Learning" Mode

The biggest problem with whitelisting is the maintenance. Developers change code, and suddenly the app breaks because it can't read a new CSS file.

To solve this, I added a Learning Mode. Instead of blocking, the agent simply records everything the application does.

  1. Run: python cli.py learn --run "flask run"
  2. Click: Use the app normally. The agent records every file open, every SQL query, every network connection.
  3. Generate: The agent dumps a policy.yaml representing the "known good" behavior.
  4. Enforce: Switch to enforce mode. Now, anything new (like an attacker trying to read /etc/shadow) is blocked because it wasn't in the learning set.

Hooking the Database

File access is easy. What about SQL Injection? We hook sqlite3.connect and wrap the cursor objects. Before any SQL is executed, we inspect it.

class CursorProxy:
    def execute(self, sql, parameters=()):
        # Basic SQL Parser
        tables = extract_tables(sql)
        caller = get_caller_context()
        
        for table in tables:
             if not policy.is_allowed("db", table, caller):
                 raise SecurityError("Unauthorized Table Access")
                 
        return self._cursor.execute(sql, parameters)

This effectively prevents SQL injection from accessing tables that the specific function shouldn't be touching. If a function is only supposed to read products, and an injection tries to UNION SELECT * FROM users, the RASP sees the access to users and kills it.

The Invisible Injection: sitecustomize

How do we get this agent into the application without changing the application's code? We use Python's sitecustomize module. This module, if present in the path, is automatically imported by Python at startup before the main script.

Our CLI simply sets PYTHONPATH to include our agent's directory. The app starts, sitecustomize loads, our hooks are installed, and then the app runs-completely unaware it's being watched.

Conclusion

Building a RASP gave me a new appreciation for the complexity of runtime environments. It's a powerful layer of defense that catches attacks conventional tools miss. It's not a silver bullet-performance overhead and strict policies can be a pain-but for critical applications, having a bodyguard inside the code is invaluable.