Yaklang题解#
题目启动需要加载编译好的二进制文件,执行要求输入flag并检查是否正确。

Golang逆向#
逆向题目本身,是Golang编写的二进制程序逆向,恢复Golang二进制文件的符号,检查代码发现大体逻辑是调用Yaklang项目中的YakVM部分代码执行编译好的Yaklang语言Opcode文件。在IDA中进行逆向分析,可以还原题目的代码如下:
func main() {
if len(os.Args) < 2 {
fmt.Println("help: ./main ./marshalCode")
os.Exit(0)
}
code, err := os.ReadFile(os.Args[1])
if err != nil {
spew.Dump(err)
panic("read code error")
}
pkg.Run(code)
}
func Run(code []byte) bool {
ret := false
m := yakvm.NewCodesMarshaller()
symbolTable, codes, err := m.Unmarshal(code)
if err != nil {
panic("unmarshal: " + err.Error())
}
vm := yakvm.NewWithSymbolTable(symbolTable)
vm.SetVar("success", func() {
println("yes! you get it!")
ret = true
})
vm.SetVar("print", func(args ...interface{}) {
fmt.Print(args...)
})
vm.SetVar("dump", spew.Dump)
vm.SetVar("append", builtin.Append)
vm.SetVar("input", func() string {
var input string
n, err := fmt.Scanln(&input)
if err != nil && n != 0 {
panic(err)
}
return input
})
vm.SetVar("string2int", func(str string) int {
i, err := strconv.Atoi(str)
if err != nil {
return 0
}
return i
})
vm.SetVar("len", func(a []uint8) int {
return len(a)
})
vm.SetVar("close", func(i interface{}) {
rv := reflect.ValueOf(i)
if rv.Kind() == reflect.Chan {
rv.Close()
}
})
vm.SetVar("func3", func(a []uint8, b []uint8) bool {
if len(a) != len(b) {
return false
}
for i, v := range a {
if v != b[i] {
return false
}
}
return true
})
vm.SetVar("js", yaklib.JSOttoExports)
err = vm.Exec(context.Background(), func(frame *yakvm.Frame) {
frame.NormalExec(codes)
})
if err != nil {
spew.Dump(err)
panic("exec code error")
}
return ret
}
YakVM分析#
可以在Github找到Yaklang项目的代码,https://github.com/yaklang/yaklang, 主要使用的是https://github.com/yaklang/yaklang/tree/main/common/yak/antlr4yak 包里的内容, 可以直接通过源码了解题目使用的API并检查VM实现和Opcode设计。
因为yaklang原本的showOpcode其实没有处理函数和defer语句,而题目中大量使用这些函数,因此这一步骤需要手动编写Golang代码调用Yaklang项目提供的API手动完成Yaklang Opcode的解析和打印。如下:
func main() {
bytes, err := os.ReadFile("./marshalCode")
if err != nil {
panic("read file error")
}
m := yakvm.NewCodesMarshaller()
_, code, err := m.Unmarshal(bytes)
if err != nil {
panic("unmarshal error")
}
showOpcode(code)
}
func showOpcode(c []*yakvm.Code) {
funs := make([]*yakvm.Function, 0)
deferCodes := make([][]*yakvm.Code, 0)
for i, code := range c {
switch code.Opcode {
case yakvm.OpDefer:
codes := code.Op1.Value.([]*yakvm.Code)
deferCodes = append(deferCodes, codes)
log.Info("defer")
fmt.Printf("%-13s %4d:%v: index:%d\n", code.RangeVerbose(), i, code.String(), len(deferCodes))
continue
case yakvm.OpPush:
v, ok := code.Op1.Value.(*yakvm.Function)
if ok {
log.Infof("function: %s", v.GetActualName())
funs = append(funs, v)
}
}
fmt.Printf("%-13s %4d:%v\n", code.RangeVerbose(), i, code.String())
}
for i, v := range deferCodes {
fmt.Printf("\n\n%s: index %d\n", "defer", i)
showOpcode(v)
}
for _, v := range funs {
fmt.Printf("\n\n%s\n", v.GetActualName())
showOpcode(v.GetCodes())
}
}
运行这段代码并得到如下所示的结构,也就是一条条的Opcode指令,并且解析出来了defer的内容和函数的内容。
Yaklang脚本逆向#
通过Opcode可以了解Yaklang脚本逻辑, 可以大概还原出Yaklang脚本的源代码:
将会出现以下几个难点:
- JS代码:题目中使用了js.Run运行了一段JavaScript代码,实现对于输入数据的AES加密。这部分执行的js代码将会是字符串形式,并且通过
Encrypt(Buffer.from("${data}"),Buffer.from("${key}"))这个位置的模板字符串将yaklang内的数据传入到js代码内。可以看到Opcode如下:

- 函数和管道的操作:题目使用了Yaklang的语言特性 管道和异步函数, 和Golang同样的使用方案用于传输数据。
,vlen:0;以及26:OP.chan-send指令,其参数为10。这些指令与延迟函数的使用相关,保证了资源回收等操作的稳定性,打乱了加密逻辑的执行顺序,是题目中关键的代码部分,与上文提到的延迟函数在设置后不会立刻调用,而是在当前函数执行结束后执行的设计相呼应。](/media/articles/ef842f9548860a8e.webp)
- 延迟函数的使用:题目中使用了延迟函数这一函数在设置以后将不会立刻调用,而是在当前函数执行结束后执行,这一设计在Golang和Yaklang中都存在,保证了资源回收等操作的稳定性,在题目中的使用将会打乱加密逻辑的执行顺序,需要选手特别关注这一结构。

- 全局变量的设置:在Yaklang脚本运行时是以来外部Golang编写的程序的,而Yaklang也提供了相当多的API用于向Yaklang脚本中注入全局函数,题目的脚本内运行的所有全局函数都是手动注入的,在Golang逆向的步骤选手将会发现,而在脚本逆向的过程中将会出现
func4函数,此函数没有被注册,将会导致错误,并立刻尝试退出当前函数,此时会进入当前函数内设置的延迟函数中。如果不能了解全局变量和延迟函数机制,将会错误的理解脚本的执行流程而导致加密算法理解出错。

加密流程#
了解正确的脚本执行流程以后,输入的数据的加密算法如下:
首先进行aes加密,key="1234567890123456",这部分是通过js代码的运行时间的,只是看着很大的代码,但是是标准的AES。可还原得到的yaklang代码如下:
key = "1234567890123456"
func1 = func(data){
_,v = js.Run(f`
function checkInt(value) {
return (parseInt(value) === value);
}
// ...... 此处省略大量代码
Encrypt(Buffer.from("${data}"),Buffer.from("${key}")) // 此处使用模版字符串
`)~
return v.Export()
}
a = func1(a)
然后通过管道操作对前一半的数据实现了^0xf0的操作,而后调用func4函数,该函数没有定义,将会产生崩溃并进入到延迟函数内,将会实现对后一半数据进行^0x0f,可b还原得到的yaklang代码如下:
func2 := () => {
defer func{
for i:=len(a)/2; i<len(a);i++{
v := a[i] ^ 0x0f
res = append(res, v)
}
}
ch := func{
ch := make(chan uint8)
go func{
for i, v := range a {
if i == len(a) / 2 {
break
}
ch <- v
}
close(ch)
}
return ch
}
// a[0:len(a)/2]
for v = range ch {
v ^= 0xf0
res = append(res, v)
}
func4(res, a)
for i,v = range a {
v ^= i
res[i] = v
a[i] = v
}
}
func2()
简化一下这一个异或运算的逻辑如下:
for i,v := range a {
if i < len(a)/2 {
a[i] = v ^ 0xf0
}else {
a[i] = v ^ 0x0f
}
}
然后程序返回上一级函数并尝试退出,调用预定义的延迟函数,改函数定义了两个延迟函数,多个延迟函数将会以先入后出的顺序执行,因此将会先执行数据运算,然后进行数据比较。
defer func{
// second
recover()
// this function in closure and native
if func3(res, b) {
success()
}else {
print("no this not flag")
}
}
defer func{
// first run
for i, v := range res {
res[i] = (v >> 2) | ((v << 6) & 0xff)
}
}
于是这一部分就是一段位运算:
for i, v := range res {
res[i] = (v >> 2) | ((v << 6) & 0xff)
}
最后进行数据比较,可以得到比较的数据是:
[]byte{
18,181,241,119,250,73,17,13,85,6,13,148,176,42,168,54,237,146,43,171,160,130,246,248,215,81,64,107,237,75,22,4
}
解密得到flag#
通过yaklang直接在yakit运行可以得到:
flag = []byte{
18,181,241,119,250,73,17,13,85,6,13,148,176,42,168,54,237,146,43,171,160,130,246,248,215,81,64,107,237,75,22,4
}
data := make([]byte, len(flag))
// shift
for i, v := range flag {
data[i] = (v >> 6) | ((v << 2) & 0xff)
}
// xor
for i := range data {
if i < len(data)/2 {
data[i] ^= 0xf0
} else {
data[i] ^= 0x0f
}
}
// aes from js
data, err := codec.AESECBDecrypt([]byte("1234567890123456"), data, nil)
if err != nil {
panic(err)
}
print(string(data))
得到flag: wdflag{xC7tYmwyXOIHuN3y}