Fourth and last post in the Offensive Callback series. This one covers what happens server-side once the callback server has an IP address — the full intelligence enrichment pipeline that runs automatically for every new source IP that hits the system.

The goal is to go from “an IP connected” to “here’s everything we know about that IP” with minimal manual work.


The IPINT table

Every unique IP that hits the server gets a record in the IPINT table. Schema:

Column Content
IP Source IP address
MAC MAC address (if available from ARP / LAN)
GEO Geolocation JSON (country, city, ISP, ASN, lat/lon)
DATA WHOIS + OSINT results (Shodan, CriminalIP, VirusTotal)
INTEL Combined intelligence summary
SCAN Boolean — whether Nmap has been run against this IP

The DATA column is a JSON object that accumulates data from multiple sources over time. Each OSINT fetch merges its result into the existing object:

1
2
const updatedData = { ...existingData, shodan: shodanData };
await ipRecord.update({ DATA: updatedData });

So a fully enriched record looks like:

1
2
3
4
5
6
{
"whois": { ... },
"shodan": { ... },
"criminalip": { ... },
"virustotal": { ... }
}

Each source is independent — if Shodan fails, CriminalIP still runs, and the partial result is stored.


Layer 1 — GeoIP (ip-api.com)

The first enrichment that runs on every new IP is geolocation, via ip-api.com. It’s free, no API key required, and fast. The server queries it server-side (not from the browser, so no CORS issues):

1
2
3
4
const response = await axios.get(
`http://ip-api.com/json/${ip}?fields=status,message,country,countryCode,regionName,city,lat,lon,isp,org,as,query`,
{ timeout: 8000 }
);

Result stored in the GEO column:

1
2
3
4
5
6
7
8
9
10
11
12
{
"country": "Spain",
"countryCode": "ES",
"regionName": "Community of Madrid",
"city": "Madrid",
"lat": 40.4165,
"lon": -3.7026,
"isp": "Telefonica de Espana",
"org": "AS3352 TELEFONICA DE ESPANA",
"as": "AS3352 TELEFONICA DE ESPANA S.A.U.",
"query": "1.2.3.4"
}

GeoIP is cached — if the GEO column is already populated, the lookup is skipped. This avoids hammering ip-api with repeated queries for the same IP and respects their free tier rate limits.

The function includes retry logic with a 3-second backoff, up to 3 attempts:

1
2
3
4
5
6
7
8
9
10
11
12
async function fetchGeoLocation(ip, retries = 3) {
try {
const response = await axios.get(/* ... */);
// ...
} catch (error) {
if (retries > 0) {
await new Promise(r => setTimeout(r, 3000));
return fetchGeoLocation(ip, retries - 1);
}
return null;
}
}

GeoIP failure is non-fatal — the rest of the enrichment pipeline runs even if geolocation fails.


Layer 2 — WHOIS

WHOIS runs right after GeoIP via the whois-json package. It queries the standard WHOIS servers for the IP’s registration information: registrant, abuse contact, network block, registration date.

1
2
3
const whoisData = await whois(ip);
const updatedData = { ...existingData, whois: whoisData };
await IPINT.update({ DATA: updatedData }, { where: { IP: ip } });

Like GeoIP, it’s cached — if DATA.whois already exists, the query is skipped. The function has clean error handling after I had a bug where a thrown error inside the catch block was silently swallowing WHOIS failures and causing downstream code to fire on undefined.

WHOIS is most useful for identifying:

  • VPS/cloud providers (AWS, DigitalOcean, Vultr, Hetzner blocks)
  • TOR exit nodes and VPN providers
  • Corporate netblocks (when someone from a company’s IP range hits your dealer)
  • Abuse contacts for reporting

Layer 3 — Shodan

Shodan integration uses the shodan-client npm package. Triggered manually from the /scanners dashboard view or via the API, never automatically (to avoid burning API credits on every hit).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ShodanClient = require('shodan-client');

async function fetchShodanData(ip, retries = 3) {
try {
const data = await ShodanClient.host(ip, process.env.SHODAN_API_KEY);
return data;
} catch (error) {
if (retries > 0) {
await new Promise(r => setTimeout(r, 5000));
return fetchShodanData(ip, retries - 1);
}
return null;
}
}

What Shodan returns for an IP that has indexed data:

  • Open ports and services (SSH, HTTP, HTTPS, RDP, SMB, etc.)
  • Service banners and version strings
  • SSL certificate details
  • Historical data — when the host was last seen, what ports were open then
  • CVEs detected (if Shodan has matched vulnerability signatures)
  • Tags: cloud, tor, vpn, scanner, honeypot

Stored under DATA.shodan. From the dashboard you can see at a glance whether a source IP is running exposed services — a corporate IP with RDP open on 3389 is interesting context for an assessment.


Layer 4 — CriminalIP

CriminalIP is a threat intelligence platform focused on IP reputation. The API call is to their asset report endpoint:

1
2
3
4
const { data } = await axios.get(
`https://api.criminalip.io/v1/asset/ip/report?ip=${ip}&full=true`,
{ headers: { 'x-api-key': process.env.CRIMINALIP_API_KEY } }
);

What comes back:

  • Abuse score: numerical threat level (0–100)
  • Port scan results: what CriminalIP sees exposed on the IP
  • Inbound/outbound connection counts: how many systems this IP has connected to or been connected from
  • Reputation categories: malicious, scanner, proxy, bot, tor
  • Recent activity: timeline of observed malicious behavior
  • Domain associations: domains that have pointed to this IP

CriminalIP is particularly good at identifying IPs from known botnets, TOR exit nodes, and commercial VPN providers. If an IP that hit your dealer shows up as a known scanner or botnet node, that’s signal about what’s actually happening vs. what looks like a real user.

Stored under DATA.criminalip. The /scanners dashboard shows the abuse score prominently and color-codes the IP row in the log view.


Layer 5 — VirusTotal

VirusTotal’s IP address endpoint aggregates results from 70+ security vendors:

1
2
3
4
const url = `https://www.virustotal.com/api/v3/ip_addresses/${ip}`;
const response = await axios.get(url, {
headers: { 'x-apikey': process.env.VIRUSTOTAL_KEY }
});

Key data from the response:

  • Malicious votes: how many engines have flagged this IP as malicious
  • Suspicious votes: how many have flagged as suspicious
  • Community score: aggregated community reputation
  • JARM hash: TLS fingerprint of the server on that IP (useful for C2 infrastructure identification)
  • Historical samples: malware samples that communicated with this IP
  • Communicating files: known malware families seen connecting to this host
  • Passive DNS: domains that have resolved to this IP over time

VirusTotal is most valuable for identifying C2 infrastructure — if a callback from what looks like a normal user’s IP shows 15/70 AV vendors flagging it as malicious, it’s worth investigating whether that user is connecting from a compromised machine or using a proxy.

Stored under DATA.virustotal.


Layer 6 — Nmap

For IPs where you want active scanning (controlled environments, authorized assessments), the server can trigger an Nmap scan via the node-nmap package. The scan result is stored and the SCAN boolean on the IPINT record is set to true.

This is never automatic — always manually triggered from the /scanners dashboard. Nmap scanning an IP without authorization is a separate legal question from passive collection, so it stays behind a manual trigger.


IP blacklisting — iptables integration

The most operationally useful defensive feature: from the /options panel you can ban an IP directly to iptables.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function banIP(ip) {
if (!isValidIP(ip)) throw new Error('Invalid IP address');

// Drop all inbound traffic from this IP
await runIptables(`-I INPUT -s ${ip} -j DROP`);
// Drop outbound responses (belt-and-suspenders)
await runIptables(`-I OUTPUT -d ${ip} -j DROP`).catch(() => {});

const list = readBlacklist();
if (!list.find(e => e.ip === ip)) {
list.push({ ip, bannedAt: new Date().toISOString() });
writeBlacklist(list);
}
}

The -I flag inserts the rule at the top of the INPUT chain, so it takes effect immediately without a restart. The blacklist is persisted to blacklist.json so rules can be restored on server restart.

Unban is symmetric:

1
2
3
4
5
6
async function unbanIP(ip) {
await runIptables(`-D INPUT -s ${ip} -j DROP`).catch(() => {});
await runIptables(`-D OUTPUT -d ${ip} -j DROP`).catch(() => {});
const filtered = readBlacklist().filter(e => e.ip !== ip);
writeBlacklist(filtered);
}

Notice the IP validation before any iptables call:

1
2
3
4
5
6
7
function isValidIP(ip) {
if (!ip || typeof ip !== 'string') return false;
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
return ip.split('.').every(n => parseInt(n, 10) <= 255);
}
return /^[0-9a-fA-F:]+$/.test(ip) && ip.includes(':');
}

This is the command injection guard. The IP goes directly into a shell command via exec('iptables ...'), so without validation, a malicious IP value in the database (think stored XSS in a log field) could execute arbitrary commands as root. The validation rejects anything that isn’t a valid IPv4 or IPv6 address.

Use cases for the ban system:

  • Scanners: automated security scanners hit the server constantly. Once identified (CriminalIP tags, Shodan scanner tag), ban them to reduce noise in the logs
  • Known bad actors: if you’re running an operation and identify an adversarial IP trying to enumerate your dealers, ban it
  • Internal ops: ban your own test IPs to keep log views clean during development

The /infoIPX view

The dashboard has a dedicated IP detail view at /infoIPX that aggregates all layers for a single IP into one page: GeoIP card, WHOIS block, Shodan ports table, CriminalIP score gauge, VirusTotal verdict count, and all log entries from that source. This is the view I use when I want to understand a specific callback — who sent it, from where, and what their infrastructure looks like.


Practical workflow

When a new IP hits and I want to understand it quickly:

  1. GeoIP is already populated automatically — check country and ISP
  2. WHOIS is already populated — check netblock owner
  3. Run Shodan from the /scanners panel — check exposed services and tags
  4. If Shodan shows scanner/honeypot/bot tags, run CriminalIP for reputation score
  5. If there are malware associations, run VirusTotal for full vendor context
  6. Decision: interesting target (keep watching), noise (ban via iptables), or known infrastructure (tag and note)

The whole pipeline is designed around the reality that most hits are noise — crawlers, scanners, accidental traffic. The OSINT layers help filter signal from noise without manually investigating every source IP.


That wraps the four-part series on Offensive Callback. The tool is available on GitHub — the project has evolved a lot since the first post introducing 5ELG back in 2024, and what I’ve been calling “Offensive Callback” is the current state of that work.