使用 golang 解析 Windows 日志的四种方法

3,775 阅读5分钟
原文链接: blog.neargle.com

关于驭龙HIDS

驭龙HIDS (github.com/ysrc/yulong…) 是一款由 YSRC 开发的入侵检测系统,集异常检测、监控管理为一体,拥有异常行为发现、快速阻断、高级分析等功能,可从多个维度行为信息中发现入侵行为。当前,驭龙agent主要会收集系统信息,计划任务,存活端口,登录日志,进程信息,服务信息,启动项,用户列表,web路径等信息,其中Windows用户登录信息读取了Windows的EventLog,经历了几个版本,更换了好几个方法,最终成型。这篇文章就稍微说一下这几种方法的思路和实现。如果有更好的思路或实现,期待各位交流指教。

Windows事件查看器与日志ID

我们知道Windows会把系统登录日志记录在Windows安全日志里,我们可以在事件查看器里筛选查看这些系统日志。

那如果要看登录相关的系统日志呢?可以用事件ID进行筛选,其中登录失败的事件ID为 4625, 而成功的登录ID为 4624。大部分的登录信息会包含在这两个事件ID里面。

贴一个我自己整理的比较重要的Windows登录相关日志的事件ID和简要介绍:

ID Name Introduction In Chinese
4624 Successful User Account Login 大部分登录事件成功时会产生的日志
4625 Failed User Account Login 大部分登录时间失败时会产生的日志(解锁屏幕并不会产生这个日志)
4672 Logon with Special Privs 特权用户登录成功时会产生的日志,例如我们登录”administrator”,一般会看到一条4624和4672日志一起出现
4648 Account Login with Explicit Credentials 一些其他的登录情况,如使用 runas /user 登录除当前以外的其他用户运行程序时,会产生这样的日志。(不过runas命令执行时同时也会产生一条4624日志)

驭龙会更加重视 4625 和 4624 两个ID的日志,以集中收集并分析系统的异常登录情况。PS. 巡风(github.com/ysrc/xunfen…) 会定期扫描内网资产中的SMB弱口令(见: crack_smb插件),如果同时使用这两个系统,为避免产生多余的告警信息,可将巡风的地址添加到驭龙的白名单内。

v1.0 使用 powershell 获取 EventLog

如果说要写程序实现获取Windows登录日志,那么作为web安全选手,可能第一个想到的就是 powershell。这个也是我们最开始的思路。

powershell里可以用 Get-WinEvent 这个 cmdlet 获取 EventLog,例如:

使用 Get-WinEvent 获取登录成功的日志:

PS C:\Windows\system32> Get-WinEvent -FilterHashtable @{'ProviderName'='Microsoft-Windows-Security-Auditing';Id=4624}


ProviderName:Microsoft-Windows-Security-Auditing

TimeCreated Id LevelDisplayName Message
----------- -- ---------------- -------
2018/1/20 15:44:47 4624 信息 已成功登录帐户。...
2018/1/20 15:32:01 4624 信息 已成功登录帐户。...
2018/1/20 15:29:36 4624 信息 已成功登录帐户。...
2018/1/20 15:29:36 4624 信息 已成功登录帐户。...
2018/1/20 15:27:34 4624 信息 已成功登录帐户。...
2018/1/20 15:24:57 4624 信息 已成功登录帐户。...

获取登录详细信息并格式化成xml代码:

&{$reslist=Get-WinEvent -FilterHashtable @{'ProviderName'='Microsoft-Windows-Security-Auditing';Id=4624};If($reslist.length){For ($index=0;$index -le $reslist.length-1;++$index){Write-Host $reslist[$index].toxml()}}Else{Write-Host $res.toxml();}}


<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'><System><Provider Name='Microsoft-Windows-Security-Auditing'
...

之后再用 golang 解析xml输出,提取我们想要的信息。这里的实现方式就比较多了。可以用正则或者 encoding/xml package 处理 xml 输出。

var RegexWindowsEvt = regexp.MustCompile(`<TimeCreated SystemTime='(?P<time>[\w\-\:]+)\.\w+'\/>.*<Data Name='TargetUserName'>(?P<username>[^<]+)</Data>.*<Data Name='TargetDomainName'>(?P<hostname>([^<]*))</Data><Data Name='Status'>(?P<status>\w+)</Data>.*<Data Name='IpAddress'>(?P<ip>[^<]+)</Data>`)

后来考虑到服务器上可能没有 powershell,且当时还想支持 Windows2003, 另外使用 golang 调用 powershell 代码,解析输出的方式也算不上优雅(可以说比较恶心了)。考虑到以上及其他一些原因我在以这种方式实现之后,又重构了这一部分的代码。

v2.0 使用 logparser 提取 EventLog

logparser 是微软官方提供的小程序,支持解析 Windows2003 之前的日志格式 evt, 也支持 Windows2008 之后的格式 evtx。 当时我们也考虑到了接下来两种比较优雅的实现方式,在尽快实现,简单直接的目的下,我依赖于 logparser 实现了 v2.0 版本。

logparser 支持像sql语句一样的搜索方式,筛选 EventLog。例如:

使用 logparser 获取用户登录信息:

logparser "SELECT EventLog,TimeGenerated,Strings,ComputerName FROM Security WHERE EventID=4624 ORDER BY TimeGenerated DESC"

EventLog TimeGenerated Strings ComputerName
-------- ------------ ---------- ---------------
......

之后再从logparser的输出中提取我们想要的信息。

out, _ := res.Output()
outstr := string(out)
lines := strings.Split(outstr, "\n")
for _, line := range lines {
    if strings.HasPrefix(line, "Security,") {
        result := make(map[string]string)
        infolist := strings.Split(line, ",")
        lpStringsList := strings.Split(infolist[2], "|")
        result["time"] = strings.TrimSpace(infolist[1])
        // 省略一部分代码
        ...
        if common.InArray([]string{"-", "127.0.0.1", "::1"}, result["remote"], false) || common.InArray(common.Config.Filter.IP, result["remote"], false) {
            continue
    }
        loglist = append(loglist, result)
    }
}

虽然这个版本不算好,但是毕竟支持了比较多的 Windows 版本,且稳定运行了一段时间。不过确实也不算优雅的实现,其实现在同程内部并没有 Windows2003 的服务器,所以接下来的实现我们决定不再支持 Windows2003,选择较为优雅可靠的实现方式。

v3.0 使用解析日志文件的方式提取 EventLog

之前的方式多多少少依赖于一些其他的工具,并没有直接读取 EventLog 的源文件。我们知道,Windows 的系统日志格式有两种,evtx和evt。其中 evt 是 Windows2003 所采用的格式,而 Evtx 是之后 Windows 采用至今的格式。

我们所需的登录日志属于 Security Event Log,日志文件路径为 C:\Windows\System32\winevt\Logs\Security.evtx。解析这个格式需要了解 evtx 的格式规范,感兴趣的同学可以参考: github.com/libyal/libe… .

在 v3.0 里面我使用了 golang-evtx 对evtx文件进行解析,调用内部接口读取 EventLog 信息。


var (
    // winodwsEvtxFile windows event log file with evtx format after win2005
    winodwsEvtxFile = "C:\\Windows\\System32\\winevt\\Logs\\Security.evtx"
    winodwsEvtxFilex32 = "C:\\Windows\\Sysnative\\winevt\\Logs\\Security.evtx"

    // timeFormat starttime format
    timeFormat = "2006-01-02T15:04:05Z07:00"

    successEventID = int64(4624)
    failedEventID = int64(4625)
    usernamePath = evtx.Path("/Event/EventData/TargetUserName")
    ipAddressPath = evtx.Path("/Event/EventData/IpAddress")
    logonTypePath = evtx.Path("/Event/EventData/LogonType")
    localAddress = []string{"-", "127.0.0.1", "::1"}

    // needlessLogonType 5:Service(by Scheduled Tasks or services)
    needlessLogonType = []string{"5"}
)

...

// Regular "winodwsEvtxFile"
evtxf, err := evtx.New(loginFile)
if err != nil {
    log.Println(err.Error())
    return nil
}

start, _ := time.Parse(timeFormat, starttime)

for event := range evtxf.FastEvents() {
    // If before start It was the data we had
    createTime := event.TimeCreated()
    if starttime != "all" && createTime.Before(start) && createTime.Equal(start) {
        continue
    }

    eventlog := make(map[string]string)
    // only need login data
    eventID := event.EventID()
    // 一些过滤和判断的代码
    ....

    logonType, _ := event.GetString(&logonTypePath)
    ipAddress, _ := event.GetString(&ipAddressPath)
    eventlog["remote"] = ipAddress
    eventlog["time"] = createTime.Format(timeFormat)
    eventlog["username"], _ = event.GetString(&usernamePath)

    loglist = append(loglist, eventlog)
}

这里经 wolf 提示还有一个大坑需要注意一下,如果把golang的代码编译成 32 位的程序的话,在64位的操作系统下是不能访问读取 C:\\Windows\\System32\\ 路径下的文件的,需要去访问 C:\\Windows\\Sysnative\\winevt\\Logs\\Security.evtx 路径才行。所以才需要定义两个路径。

虽然我很喜欢 golang-evtx 的接口规范和实现,但是这个库毕竟小众。和 wolf 重新调研了一下, wolf 决定参考并调用 github.com/elastic/bea… 的代码,以调用 Windows 系统动态链接库的方式再次重构这段代码。

v4.0 调用 wevtapi.dll 获取 Event Log

wevtapi.dll 是 windows2008 之后内置于系统 path 的动态链接库,内置如 EvtQuery 等多个函数可处理 Windows 系统日志。 elastic/beats 也是调用了 wevtapi.dll 监控并读取 Event Log的。

golang 的库有一个特点: 即使是项目里面编写的子 package,也可以做单独的 package 处理, 调用的时候可以避免把整个大项目编译到程序里,只编译固定的子 package。这里我们只需要 beats 的两个子 package 就好了。(但是 go get 的时候,还是会把整个项目 clone 到 $GOPATH 里。 驭龙的源码把第三方依赖全部放在 vendor 下,实际编译的时候无需 go get 这两个库)。

import (
	"github.com/elastic/beats/winlogbeat/sys"
	win "github.com/elastic/beats/winlogbeat/sys/wineventlog"
)

这个就是驭龙获取 Windows 登录日志最终版本代码了。由wolf大大参考 beats 源码编写,应该是目前最优的实现方式之一了。

// code by wolf
func newWinEventLog(eventID string) (EventLog, error) {
	var ignoreOlder time.Duration
	if first {
		ignoreOlder = time.Hour * 17520
		first = false
	} else {
		ignoreOlder = time.Second * 60
	}
	query, err := win.Query{
		Log:         "Security",
		IgnoreOlder: ignoreOlder,
		Level:       "",
		EventID:     eventID,
		Provider:    []string{},
	}.Build()
	if err != nil {
		return nil, err
	}

	l := &winEventLog{
		query:       query,
		channelName: "Security",
		maxRead:     1000,
		renderBuf:   make([]byte, renderBufferSize),
		outputBuf:   sys.NewByteBuffer(renderBufferSize),
	}

	l.render = func(event win.EvtHandle, out io.Writer) error {
		return win.RenderEvent(event, 0, l.renderBuf, nil, out)
	}
	return l, nil
}

// GetLoginLog 获取系统登录日志
func GetLoginLog() (resultData []map[string]string) {
	var loginFile string
	var timestamp int64
	if common.Config.Lasttime == "all" {
		timestamp = 615147123
	} else {
		ti, _ := time.Parse("2006-01-02T15:04:05Z07:00", common.Config.Lasttime)
		timestamp = ti.Unix()
	}
	if runtime.GOARCH == "386" {
		loginFile = winodwsEvtxFilex32
	} else {
		loginFile = winodwsEvtxFile
	}
	if _, err := os.Stat(loginFile); err != nil {
		// 不支持2003
		log.Println(err.Error())
		return
	}
	resultData = getSuccessLog(timestamp)
	resultData = append(resultData, getFailedLog(timestamp)...)
	return
}

func getSuccessLog(timestamp int64) (resultData []map[string]string) {
	l, err := newWinEventLog("4625")
	if err != nil {
		return
	}
	err = l.Open(0)
	if err != nil {
		return
	}
	reList, _ := l.Read()
	for _, rec := range reList {
		// rec.EventData.Pairs[10].Value != "5" &&
		if rec.TimeCreated.SystemTime.Local().Unix() > timestamp {
			if common.InArray(localAddress, rec.EventData.Pairs[19].Value, false) {
				continue
			}
			m := make(map[string]string)
			m["status"] = "true"
			m["username"] = rec.EventData.Pairs[5].Value
			m["remote"] = rec.EventData.Pairs[19].Value
			m["time"] = rec.TimeCreated.SystemTime.Local().Format("2006-01-02T15:04:05Z07:00")
			resultData = append(resultData, m)
		}
	}
	return
}
func getFailedLog(timestamp int64) (resultData []map[string]string) {
	l, err := newWinEventLog("4624")
	if err != nil {
		return
	}
	err = l.Open(0)
	if err != nil {
		return
	}
	reList, _ := l.Read()
	for _, rec := range reList {
		// rec.EventData.Pairs[8].Value != "5" &&
		if rec.TimeCreated.SystemTime.Local().Unix() > timestamp {
			if common.InArray(localAddress, rec.EventData.Pairs[18].Value, false) {
				continue
			}
			m := make(map[string]string)
			m["status"] = "false"
			m["username"] = rec.EventData.Pairs[5].Value
			m["remote"] = rec.EventData.Pairs[18].Value
			m["time"] = rec.TimeCreated.SystemTime.Local().Format("2006-01-02T15:04:05Z07:00")
			resultData = append(resultData, m)
		}
	}
	return
}

后日谈

这就是驭龙实现 Windows 日志解析的发展过程了,也算是迭代了好几个版本。其中有很多方式和思路在渗透测试过程中也可以使用,希望能给大家带来点帮助。

如果大家有更好的建议和实现方法,期待来自大家的交流和反馈。