级联 ssh 动态端口转发

易雾君
发布于 2022-09-17 / 636 阅读
1
0

级联 ssh 动态端口转发

导读

周末六假日清闲,找寻到一个好几年前用 golang 写过的一个小玩意儿,基于 ssh 的级联隧道工具,遥想当年 golang 在内网漫游,免杀效果非常给力,最是喜欢用它来实现各类工具,现分享一枚动态端口转发的例子,支持 socks5 出口代理,嵌套级联若干 ssh ,红队视角可用于正向跳板代理,目标视角可以用于构建出网隧道,突破内网封锁。

ssh 闲谈

ssh 被广泛用于对远程服务器建立安全管理的入口,执行命令是最常见的情形,但其服务端默认开启 Tcp 转发功能模块,红队热衷于把它用于动态端口转发、本地端口转发、远程端口转发(需手工开启一项功能)。首先 ssh 属于目标环境自带工具,因地制宜,无需上传代理工具部署,减少告警几率。其次, ssh 隧道天然是加密态,可防止网络流量抓包分析。说了那么多,在没有掌握对应环境的账号密码或者认证私钥,都是空谈,各位师傅自有办法,比如爆破、挖掘 ssh 协议漏洞等,这里不展开。

隧道需求分析

据我所知,比较讲究的红队人员通常是不允许自身机器直联外网的,而是通过手动配置代理间接联网,考虑到这场景,该工具实现了支持 socks5 代理类型作为网络出口。

在代理级联方面,小易用过最好用的当属上海哥们的一个开源项目 gost ,不过当时写本文这工具的时候,貌似满足不了 ssh 的嵌套级联,故自实现之,不知现在是否已实现,各位师傅移步自行验证。

工具获取

git clone https://github.com/evling2020/ssh-d.git
cd ssh-d
go get
GO111MODULE=off go build -ldflags "-s -w"

场景演示

单个 ssh 隧道 - 密码认证

# ssh 服务器地址: 10.70.6.2
# 用户名: test
# 密码: test@4321!
# 入口端口:1080
# 入口协议:目前仅支持sock5
./ssh-d -L=:1080 -F=ssh://test:test@4321\[email protected]:22

单个 ssh 隧道 - 公私钥认证

# ssh 服务器地址: 10.70.6.2
# 用户名: test
# 私钥文件: /home/jmeade/.ssh/id_rsa
# 入口端口:1080
# 入口协议:目前仅支持sock5
./ssh-d -L=:1080 -F=ssh://[email protected]:22/home/jmeade/.ssh/id_rsa

综合例子

先连接出网口的 socks5 代理,再途径两个 ssh 远程跳板,最后访问目标

# 暂不支持出口代理认证
./ssh-d -L=:1080 -F=socks5://10.70.6.2:1080 -F=ssh://test:test@4321\[email protected]:22 -F=ssh://[email protected]:22/home/jmeade/.ssh/id_rsa

查看中间的 ssh 服务器 10.70.6.3 网络连接情况,可以看出 10.70.6.2 连入它的 22 端口,它又接着连接 10.70.6.4 的 22 端口,串联成功。

root@07-001-tpl-debian-privileged:~# netstat -ltnpa | grep test
tcp        0      0 10.70.6.3:22            10.70.6.2:59546         ESTABLISHED 692/sshd: test [pri 
tcp        0      0 10.70.6.3:40362         10.70.6.4:22            ESTABLISHED 709/sshd: test

配置本地代理访问目标的一个 web 资产

Screen Shot 2022-09-17 at 22.50.16

出口代理连接日志

Screen Shot 2022-09-17 at 22.50.37

查看目标web资产日志远程接入地址正式链条末端的 10.70.6.4,验证完毕。

{ "@timestamp": "2022-09-17T22:40:23+08:00","@source": "10.68.15.2","hostname": "02-015-share-center","http_x_forwarded_for": "-", "remote_addr": "10.70.6.4", "remote_user": "-", "request_method": "GET","scheme": "https","domain": "ldap.evling.tech","http_referer": "-", "request_uri": "/", "args": "-","body_bytes_sent": "153", "status": " 403", "http_user_agent": "curl/7.85.0", "https": "on", "time_local": "17/Sep/2022:22:40:23 +0800", "request_time": "0.000", "upstream_response_time": "-","upstream_addr": "-","trace_id": "-", "span_id": "-" }

源码实现

package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/url"
	"strconv"
	"strings"
	"io/ioutil"
	"golang.org/x/crypto/ssh"
	"golang.org/x/net/proxy"
)

const (
	socks5Version 	 	= uint8(5)
	ipv4Address      	= uint8(1)
	fqdnAddress      	= uint8(3)
	ipv6Address      	= uint8(4)
	connectCommand   	= uint8(1)
	noAuthentication 	= uint8(0)
	successReply 	 	= uint8(0)
	commandNotSupported = uint8(7)
	unAssigned		 	= uint8(9)
)

type arrayChainsFlag []string

func (i *arrayChainsFlag) String() string {
	return "forward address, can make a forward chain"
}
func (i *arrayChainsFlag) Set(value string) error {
	*i=append(*i,value)
	return nil
}

var (
	chainNodes 	arrayChainsFlag
	serveNode 	string
)

var (
	unrecognizedAddrType = fmt.Errorf("Unrecognized address type")
)

type Request struct {
	Version uint8
	Command uint8
	DestAddr *AddrSpec
}

type AddrSpec struct {
	FQDN string
	IP   net.IP
	Port int
}
func (a *AddrSpec) String() string {
	if a.FQDN != "" {
		return fmt.Sprintf("%s (%s):%d", a.FQDN, a.IP, a.Port)
	}
	return fmt.Sprintf("%s:%d", a.IP, a.Port)
}
func (a AddrSpec) Address() string {
	if 0 != len(a.IP) {
		return net.JoinHostPort(a.IP.String(), strconv.Itoa(a.Port))
	}
	return net.JoinHostPort(a.FQDN, strconv.Itoa(a.Port))
}

type Node struct{
	Protocol 		string
	Username 		string
	Password 		string
	Host     		string
	PrivateKeyPath 	string
	config 			*ssh.ClientConfig
}

func parseNode(s string) (*Node,error) {
	if s==""{
		return &Node{},nil
	}
	if !strings.Contains(s, "://") {
		s = "socks://" + s
	}
	u, err := url.Parse(s)
	if err != nil {
		return &Node{},err
	}
	pw,boolVar:=u.User.Password()
	if !boolVar {
		pw=""
	}
	node := Node{
		Protocol:u.Scheme,
		Username: u.User.Username(),
		Password: pw,
		Host:   u.Host,
		PrivateKeyPath: u.Path,
	}
	switch node.Protocol{
	case "socks","socks5":
		node.Protocol="socks5"
	case "ssh":
		if node.PrivateKeyPath == "" {
			node.config = &ssh.ClientConfig{
				User: node.Username,
				Auth: []ssh.AuthMethod{
					ssh.Password(node.Password),
				},
				HostKeyCallback: ssh.InsecureIgnoreHostKey(),
			}
		} else {
			key, err :=ioutil.ReadFile(node.PrivateKeyPath)
			if err != nil {
				return &Node{},err
			}
			signer, err := ssh.ParsePrivateKey(key)
			if err != nil {
				return &Node{},err
			}
			node.config = &ssh.ClientConfig{
				User: node.Username,
				Auth: []ssh.AuthMethod{
					ssh.PublicKeys(signer),
				},
				HostKeyCallback: ssh.InsecureIgnoreHostKey(),
			}
	}
	default:
		node.Protocol=""
	}
	return &node,nil
}

func init()  {
	flag.Var(&chainNodes, "F","forward address, can make a forward chain")
	flag.StringVar(&serveNode, "L", "","listen address, can listen on multiple ports")
	flag.Parse()
}

func newSshChain(sshClientConn *ssh.Client,node *Node) (*ssh.Client ,error) {
	var err error
	if sshClientConn==nil{
		if sshClientConn, err = ssh.Dial("tcp", node.Host,node.config);err!=nil{
			return nil,err
		}
	} else {
		conn, err := sshClientConn.Dial("tcp", node.Host)
		if err!=nil{
			return nil,err
		}
		sshClientConn,err=Dialer(conn,node)
		if err!=nil{return nil,err}
	}
	return sshClientConn,nil
}

func proxiedSSHClient(proxyNode,node *Node) (*ssh.Client, error) {
	dialer, err := proxy.SOCKS5("tcp", proxyNode.Host, nil, proxy.Direct)
	if err != nil {
		return nil, err
	}

	conn, err := dialer.Dial("tcp", node.Host)
	if err != nil {
		return nil, err
	}

	c, chans, reqs, err := ssh.NewClientConn(conn, node.Host, node.config)
	if err != nil {
		return nil, err
	}

	return ssh.NewClient(c, chans, reqs), nil
}

func Dialer(c net.Conn, node *Node) (*ssh.Client, error) {
	conn, chans, reqs, err := ssh.NewClientConn(c, node.Host, node.config)
	if err != nil {
		return nil, err
	}
	return ssh.NewClient(conn, chans, reqs), nil
}

func main()  {
	var sshClientConn *ssh.Client
	var exitSocksProxy *Node
	var err  error
	for index, nodeString:=range chainNodes {
		var cnode *Node
		cnode,_ = parseNode(nodeString)
		if index==0 &&cnode.Protocol=="socks5" {
			exitSocksProxy=cnode
			continue
		}
		if index==1 && exitSocksProxy!=nil{
			sshClientConn, err = proxiedSSHClient(exitSocksProxy,cnode)
			if err != nil {
				log.Fatalln(err)
			}
			continue
		}
		sshClientConn,err=newSshChain(sshClientConn,cnode)
		if err != nil {
			log.Fatalln(err)
		}
	}
	if err:=listenAndServe("tcp",serveNode,sshClientConn);err!=nil{
		log.Fatalln(err)
	}
}

func listenAndServe(network, addr string,sshClientConn *ssh.Client) error {
	l, err := net.Listen(network, addr)
	if err != nil {
		return err
	}
	return serve(l,sshClientConn)
}

func serve(l net.Listener,sshClientConn *ssh.Client) error {
	for {
		conn, err := l.Accept()
		if err != nil {
			return err
		}
		go handleRequest(conn, sshClientConn)
	}
	return nil
}

// reply the request
// |VER | REP |  RSV  | ATYP | BND.ADDR | BND.PORT |
// +----+-----+-------+------+----------+----------+
// | 1  |  1  | X'00' |  1   | Variable |    2     |
func handleRequest(conn net.Conn , sshClientConn *ssh.Client){
	defer conn.Close()
	if err:=extractNegatiation(conn);err!=nil{
		log.Println(err)
	}
	if err:=replyNegotiation(conn);err!=nil{
		log.Println(err)
	}
	req,err:=newRequest(conn)
	if err!=nil{
		log.Println(err)
	}
	switch req.Command {
	case connectCommand:
		if err:=handleConnect(conn,req,sshClientConn);err!=nil{
			log.Println(err)
		}
	default:
		if err:=sendReply(conn,commandNotSupported,nil);err!=nil{
			log.Println( fmt.Errorf("Failed to send reply: %v", err))
		}
		log.Println(fmt.Errorf("Unsupported command: %v", req.Command))
	}
}

func handleConnect(conn net.Conn,req *Request,sshClientConn *ssh.Client) error {
	target, err := sshClientConn.Dial("tcp", req.DestAddr.Address())
	if err!=nil{
		if err := sendReply(conn, unAssigned, nil); err != nil {
			return fmt.Errorf("Failed to send reply: %v", err)
		}
		return fmt.Errorf("Connect to %v failed: %v", req.DestAddr, err)
	}
	log.Println("access: ", req.DestAddr.Address())
	defer target.Close()
	local := target.LocalAddr().(*net.TCPAddr)
	bind := AddrSpec{IP: local.IP, Port: local.Port}
	if err := sendReply(conn, successReply, &bind); err != nil {
		return fmt.Errorf("Failed to send reply: %v", err)
	}
	errCh := make(chan error, 2)
	go connCopy(target, conn, errCh)
	go connCopy(conn, target, errCh)
	for i := 0; i < 2; i++ {
		e := <-errCh
		if e != nil {
			return e
		}
	}
	return nil
}

func connCopy(dst , src net.Conn, errCh chan error) {
	_, err := io.Copy(dst, src)
	errCh <- err
}

func extractNegatiation(conn net.Conn)(error)  {
	negatiationHeader:=make([]byte,260)
	if _,err:=conn.Read(negatiationHeader);err!=nil{
		return fmt.Errorf("Failed to get negatiation: %v", err)
	}
	if negatiationHeader[0] != socks5Version {
		return fmt.Errorf("Unsupported socks5 version: %v", negatiationHeader[0])
	}
	return nil
}

func newRequest(conn net.Conn)(*Request,error)  {
	header:=[]byte{0,0,0}
	if _, err := conn.Read(header); err != nil {
		return nil, fmt.Errorf("Failed to get command version: %v", err)
	}
	if header[0] != socks5Version {
		return nil, fmt.Errorf("Unsupported command version: %v", header[0])
	}
	dest,err:=readAddrSpec(conn)
	if err!=nil{
		return nil,err
	}

	return &Request{
		Version:socks5Version,
		Command:header[1],
		DestAddr:dest,
	},nil
}

func readAddrSpec(conn net.Conn) (*AddrSpec, error) {
	d := &AddrSpec{}
	addrType := []byte{0}
	if _, err := conn.Read(addrType); err != nil {
		return nil, err
	}
	switch addrType[0] {
	case ipv4Address:
		addr := make([]byte, 4)
		if _, err := conn.Read(addr); err != nil {
			return nil, err
		}
		d.IP = net.IP(addr)
	case ipv6Address:
		addr := make([]byte, 16)
		if _, err := conn.Read(addr); err != nil {
			return nil, err
		}
		d.IP = net.IP(addr)
	case fqdnAddress:
		if _, err := conn.Read(addrType); err != nil {
			return nil, err
		}
		addrLen := int(addrType[0])
		fqdn := make([]byte, addrLen)
		if _, err := conn.Read(fqdn); err != nil {
			return nil, err
		}
		d.FQDN = string(fqdn)
	default:
		return nil, unrecognizedAddrType
	}
	port := []byte{0, 0}
	if _, err := conn.Read(port); err != nil {
		return nil, err
	}
	d.Port = (int(port[0]) << 8) | int(port[1])
	return d, nil
}

func replyNegotiation(conn net.Conn) error {
	if _, err := conn.Write([]byte{socks5Version, noAuthentication}); err != nil {
		return err
	}
	return nil
}

func sendReply(conn net.Conn, resp uint8, addr *AddrSpec) error {
	var addrType uint8
	var addrBody []byte
	var addrPort uint16
	switch {
	case addr == nil:
		addrType = ipv4Address
		addrBody = []byte{0, 0, 0, 0}
		addrPort = 0
	case addr.FQDN != "":
		addrType = fqdnAddress
		addrBody = append([]byte{byte(len(addr.FQDN))}, addr.FQDN...)
		addrPort = uint16(addr.Port)
	case addr.IP.To4() != nil:
		addrType = ipv4Address
		addrBody = []byte(addr.IP.To4())
		addrPort = uint16(addr.Port)
	case addr.IP.To16() != nil:
		addrType = ipv6Address
		addrBody = []byte(addr.IP.To16())
		addrPort = uint16(addr.Port)
	default:
		return fmt.Errorf("Failed to format address: %v", addr)
	}
	msg := make([]byte, 6+len(addrBody))
	msg[0] = socks5Version
	msg[1] = resp
	msg[2] = 0
	msg[3] = addrType
	copy(msg[4:], addrBody)
	msg[4+len(addrBody)] = byte(addrPort >> 8)
	msg[4+len(addrBody)+1] = byte(addrPort & 0xff)
	_, err := conn.Write(msg)
	return err
}

评论