Skip to main content

Automating IDOR Detection: Writing Custom Nuclei Templates for Business Logic Vulnerabilities

 Standard Dynamic Application Security Testing (DAST) tools are notoriously bad at detecting Insecure Direct Object References (IDOR). Tools like OWASP ZAP or Arachni typically operate on a fuzzing basis—they throw garbage data at inputs and look for crashes or 500 errors.

They fail at IDORs because an IDOR is not a syntactic error; it is a logic error. If User A requests User B’s invoice and the server returns a 200 OK with the invoice data, a generic scanner interprets this as a successful, valid request. It lacks the context to know that User A should not have access to that data.

Reliance on manual testing (Burp Suite Repeater) for these checks introduces a bottleneck. As you move towards continuous deployment, you need a way to codify "User A accessing User B data" into a regression test that runs on every commit.

The Root Cause: Context-Blind Authorization

Under the hood, most web frameworks separate Authentication (Who are you?) from Authorization (What can you do?).

In a typical IDOR vulnerability, the code checks Authentication but fails to scope the database query to the authenticated user.

Vulnerable logic (Node.js/Express example):

// ❌ Vulnerable: Checks if user is logged in, but trusts the ID in the param
app.get('/api/invoices/:id', ensureAuthenticated, async (req, res) => {
  const invoiceId = req.params.id;
  // This query finds the invoice regardless of who owns it
  const invoice = await db.invoices.findUnique({ where: { id: invoiceId } });
  
  if (!invoice) return res.status(404).json({ error: "Not found" });
  res.json(invoice);
});

Secure logic:

// ✅ Secure: Scopes the query to the requesting user
app.get('/api/invoices/:id', ensureAuthenticated, async (req, res) => {
  const invoiceId = req.params.id;
  const userId = req.user.id; // Extracted from valid session/JWT
  
  // The query enforces ownership
  const invoice = await db.invoices.findFirst({ 
    where: { 
      id: invoiceId,
      ownerId: userId 
    } 
  });
  
  if (!invoice) return res.status(404).json({ error: "Not found" });
  res.json(invoice);
});

Automating the detection of the vulnerability requires a tool that can replicate the "Attacker" state and verify the content of the response against known "Victim" data.

The Fix: Multi-Step Nuclei Templates

Project Discovery's Nuclei is the industry standard for this because it supports workflows and dynamic variable extraction. We can script a scenario where the engine:

  1. Authenticates as the Attacker.
  2. Extracts the Attacker's Session Token.
  3. Attempts to access a known Victim Resource ID.
  4. Asserts that Victim PII is present in the response.

The Template Strategy

We will write a template that performs a logic chain. To make this portable across environments (QA, Staging, Prod), we will not hardcode credentials. We will inject them via CLI variables.

Scenario:

  • Attacker: attacker@example.com
  • Victim Resource ID: 5501 (A resource known to belong to a different user)
  • Success Condition: The server returns HTTP 200 AND the response body contains a specific string unique to the victim (e.g., "Victim Name" or a specific tax ID).

idor-check.yaml

id: automated-idor-invoice
info:
  name: IDOR Detection on Invoice Endpoint
  author: devsecops-team
  severity: high
  description: Authenticates as User A and attempts to access User B's invoice.
  tags: logic, idor, authenticated

# Dynamic variables passed at runtime
variables:
  attacker_email: "{{attacker_email}}"
  attacker_password: "{{attacker_password}}"
  victim_invoice_id: "{{victim_invoice_id}}"
  victim_string_match: "{{victim_string_match}}"

requests:
  - raw:
      # Step 1: Login as Attacker to get a valid token
      - |
        POST /api/login HTTP/1.1
        Host: {{Hostname}}
        Content-Type: application/json

        {
          "email": "{{attacker_email}}",
          "password": "{{attacker_password}}"
        }

      # Step 2: Use the token to access the Victim's resource
      - |
        GET /api/invoices/{{victim_invoice_id}} HTTP/1.1
        Host: {{Hostname}}
        Authorization: Bearer {{auth_token}}
        Content-Type: application/json

    # Logic to handle the flow between Request 1 and Request 2
    extractors:
      - type: json
        part: body
        name: auth_token
        json:
          - '.token' # JQ syntax to extract JWT from login response
        internal: true # This variable is used only within this template

    matchers-condition: and
    matchers:
      # Condition 1: Request must succeed (200 OK)
      - type: status
        status:
          - 200

      # Condition 2: Response must contain data belonging to the victim
      # This prevents false positives where the server returns 200 but sends a custom "Access Denied" HTML page.
      - type: word
        part: body
        words:
          - "{{victim_string_match}}"

Execution Command

To run this in a CI pipeline or local terminal, you inject the context variables.

nuclei -t idor-check.yaml \
       -target https://staging-api.platform.com \
       -var attacker_email="hacker@test.com" \
       -var attacker_password="Password123!" \
       -var victim_invoice_id="9901" \
       -var victim_string_match="Victim Corp Ltd"

The Explanation

1. Request Chaining

The requests block contains two separate HTTP requests. Nuclei executes them sequentially. The critical component here is the connection between request 1 and request 2.

2. Internal Extractors

In Step 1 (Login), we receive a JSON response containing a JWT or Session ID.

extractors:
  - type: json
    name: auth_token
    json:
      - '.token'
    internal: true

The internal: true flag tells Nuclei not to print this token to the console as a finding, but to hold it in memory. We then reference it in Step 2 using {{auth_token}}. This mirrors the behavior of a browser or an API client maintaining a session.

3. Logic-Based Matchers

A status code of 200 is insufficient for IDOR detection. Many modern Single Page Applications (SPAs) return 200 OK even on errors, with a JSON body like { "error": "Unauthorized" }.

To confirm a true positive, we use a Word Matcher on the body. We look for a string that only exists if the exploit is successful (e.g., the Victim's name, email, or partial address).

matchers-condition: and
matchers:
  - type: status ...
  - type: word ...

The matchers-condition: and ensures an alert is raised only if the status is 200 AND the victim's data is leaked.

Integration into CI/CD

To prevent regression, this template should run post-deployment in your staging environment.

Here is a simplified GitHub Actions step to execute this check.

jobs:
  security-regression:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Nuclei
        uses: projectdiscovery/nuclei-action@v1
        
      - name: Run IDOR Checks
        run: |
          nuclei -t ./security-tests/idor/ \
            -target ${{ secrets.STAGING_URL }} \
            -var attacker_email="${{ secrets.TEST_USER_A_EMAIL }}" \
            -var attacker_password="${{ secrets.TEST_USER_A_PASS }}" \
            -var victim_invoice_id="${{ secrets.TEST_USER_B_INVOICE_ID }}" \
            -var victim_string_match="${{ secrets.TEST_USER_B_NAME }}" \
            -json -o results.json

      - name: Fail on Vulnerability
        run: |
          if grep -q "automated-idor-invoice" results.json; then
            echo "CRITICAL: IDOR Regression Detected!"
            exit 1
          fi

Conclusion

Generic scanners provide a false sense of security regarding business logic. By defining "Attacker" and "Victim" states explicitly in Nuclei templates, you transform manual penetration testing steps into fast, repeatable code. This moves IDOR detection from a quarterly pentest activity to a daily build quality gate.