REVERSING

PINTOOL for CTF / codegate 2020 prequal simple machine

jjy security 2020. 2. 26. 21:39

워게임이나 실시간 CTF에서 가끔 "input check" 문제가 등장한다. 사용자가 flag를 입력하면 "OK" 등의 문자열을 출력하는 프로그램이다. 시간 제약이 있는 실시간 CTF에서 이러한 문제에 시간을 적게 들이고 풀 수 있다면 좋을 것 같았다. PINTOOL을 이용하여 실행되는 명령어 수를 측정하여 input check 문제를 side channel attck으로 풀어보았다. 


COMPILE inscount.so

 

pin은 아래 링크에서 다운로드할 수 있다.

https://software.intel.com/en-us/articles/pin-a-binary-instrumentation-tool-downloads

 

리눅스용 압축파일을 다운로드 후 압축해제를 하면 아래와 같은 파일들이 나온다. pin은 pin 바이너리이다. 

"source/tools/"에 pintools 예시 코드들이 있다. 

이 중에서 적당한 폴더에 들어간 다음 "make"를 입력하면 pintools 라이브러리가 컴파일된다. 이제 side channel attack에 필요한 라이브러를 만들어보자. 

 

링크에 inscount.cpp를 보면 프로그램에서 실행되는 명령어 수를 측정하는 프로그램임을 알 수 있다. 다만 문제점이 있다. 실행된 명령어의 수가 파일을 형식으로 출력된다는 것이다. 이는 나중에서 side channel attack을 위해 python code를 작성할 때 thread를 이용하기 번거롭게 한다. 따라서 실행한 명령어 수를 파일이 아닌 표준 출력으로 쓰게 하였다. ("using namespace std"가 없어서 에러가 발생했다. 이 부분도 추가했다. )

https://software.intel.com/sites/landingpage/pintool/docs/81205/Pin/html/

 

// inscount0.cpp

#include <iostream>
#include <fstream>
#include "pin.H"

using namespace std;
ofstream OutFile;

static UINT64 icount = 0;
VOID docount() { icount++; }

VOID Instruction(INS ins, VOID *v)
{
        // Insert a call to docount before every instruction, no arguments are passed
        INS_InsertCall(ins, IPOINT_BEFORE, (AFUNPTR)docount, IARG_END);
}
KNOB<string> KnobOutputFile(KNOB_MODE_WRITEONCE, "pintool",
	    "o", "inscount.out", "specify output file name");
// This function is called when the application exits

VOID Fini(INT32 code, VOID *v)
{
	    cout << icount << endl;
}

/* ===================================================================== */
/* Print Help Message                                                    */
/* ===================================================================== */

INT32 Usage()
{
        cerr << "This tool counts the number of dynamic instructions executed" << endl;
	    cerr << endl << KNOB_BASE::StringKnobSummary() << endl;
	        return -1;
}

/* ===================================================================== */
/* Main                                                                  */
/* ===================================================================== */
/*   argc, argv are the entire command line: pin -t <toolname> -- ...    */
/* ===================================================================== */

int main(int argc, char * argv[])
{
        // Initialize pin
        if (PIN_Init(argc, argv)) return Usage();
	        // Register Instruction to be called to instrument instructions
	        INS_AddInstrumentFunction(Instruction, 0);
		    // Register Fini to be called when the application exits
		    PIN_AddFiniFunction(Fini, 0);
			PIN_StartProgram();
			return 0;

}

"make"로 컴파일 한 후 테스트 해보았다. 성공적으로 작동한다. 


실제 CTF 문제를 풀기 전에 아주 간단한 문제를 만들고 풀어보았다. flag가 눈으로 보이지만, side channel attack이 유효한지 보기 위한 예시이니 신경쓰지 말자. 

#include<stdio.h>
#include<string.h>
#include<stdlib.h>


int obfuscation(char flag, int idx){
    char * test="flag{flag_is_flag}";
    if( ! (flag^test[idx])){
	return 1;
    };
    return 0;
}


void main(){
    char flag[0x20];
    int end=read(0,flag,0x20);
    flag[end]='\x00';
    for(int i =0;i<strlen("flag{flag_is_flag}");i++){
	if(obfuscation(flag[i],i)==0){
	    exit(1);
	};
    }
    printf("OK!!");

}

 

 

PINTOOLS을 자동화하기 위해 python을 이용했다. 간단하게 만든 템플릿은 아래와 같다. 3가지 모드가 있다.

 

  • len : 입력값의 길이를 변화시키며 실행되는 명령어 수 측정 
  • crack : 입력값을 한 바이트 씩 변화시키며 실행되는 명령어 수 측정
  • check : 보통 CTF에서는 flag 형식을 주는 경우가 많다. 이때 flag 형식이 ide channel attack이 유효한지 파악하는데 될 수도 있다. 
from subprocess import PIPE, Popen
import string
import sys
import os
import getopt
from threading import Thread


def cmdline(command):
    process = Popen(args=command,stdout=PIPE,shell=True).stdout
    return process.read()

st=string.printable
count=0
first=0
fisrt_st=""
flag=""

try:
    opts,etc_args= getopt.getopt(sys.argv[1:],"hf:m:l:s:a:",["help","file=","mode=",'len=','start_with=',"argv="])
except getopt.GetoptError:
    print("-f <file> -m <mode> -l <len> -s <start_with> -a <argv for file>")
    exit(1)

for opt,arg in opts:
    if opt=="-f":
	file_name=arg
    elif opt=="-m":
	mode=arg
    elif opt=="-l":
	le=int(arg)
    elif opt=="-s":
	start=arg
    elif opt=="-a":
	argv=arg



if mode=="len":
    for x in xrange(le):
	cmd="/bin/echo '"+"A"*x+"' | ../../pin -t ../../inscount.so -- ./"+file_name
	count=cmdline(cmd)#.read()
	count=int(count)
	if(first<count):
	    first=count
	    print("x = %d count = %d"%(x+1,count))


if mode=="crack":
    for x in xrange(le):
	print("%dcycle"%(x+1))
	for y in xrange(len(st)):
	    if st[y]=="'" or "'" in flag:
		count=cmdline('/bin/echo "'+flag+st[y]+'" | ../../pin -t ../../inscount.so -- ./'+file_name)
	    else:
		count=cmdline("/bin/echo '"+flag+st[y]+"' | ../../pin -t ../../inscount.so -- ./"+file_name)
	    count=int(count)
	    if(first<count):
		first=count
		first_st=st[y]
		print("x = %s count = %lx"%(st[y],count))
	flag+=first_st#in my experience. In this condition st[y] is flag 
	print(flag)
if mode=="check":
    print("check side channel attack")
    real=start
    for x in xrange(int(len(real))):
	count=cmdline('/bin/echo "'+flag+real[x]+'" | ../../pin -t ../../inscount.so -- ./'+file_name)#get target by cmdline
	non=cmdline("/bin/echo '"+"a"*(x+1)+"' | ../../pin -t ../../inscount.so -- ./"+file_name)
	flag+=real[x]
	print "flag = %s  %s"%(flag, count)
	print "NON = %s %s"%("a"*(x+1),non)

 

flag 형식이 "flag"라고 생각하자. check 모드를 통해 side channel attack의 유효성을 예상해보았다. aaaa가 입력되었을 때보다 "flag"가 입력되었을 때 더 많은 코드가 실행되는 것을 볼 수 있다. 이를 통해 side channel attack의 가능성을 짐작할 수 있다. 또한 한 바이트를 더 입력할 때마다 실행되는 코드가 규칙적으로 증가하는 것을 보아 flag 체크로직이 한 바이트씩 검증한다고 짐작할 수 있다. 

 

 

길이에 대한 side channel 어택을 해보니 결과가 제대로 나오지 않는 것을 볼 수 있다. 실제로 prob.c를 보면 길이에 대한 검증로직이 없다. 

 

 

side channel attack의 가능성을 보았으니 crack을 해보았다. 정상적으로 flag를 출력하는 것을 볼 수 있다. 

 

➜  test python ./solve.py -f ./a.out -m crack -l 20

 


pintools을 이용한 side channel attack을 실제 CTF 문제에 적용해보자. 문제는 CODEGATE 2020 예선전에서 출제되었던 simple machine이다. 

 

해당 문제를 리버싱해보면 VM 형식을 가지고 있다는 것을 금방 알 수 있다. 또한 VM 코드 외부에서 당시 flag 형식이었던 "CODEGATE2020"을 검증하거나 flag 길이를 검증하는 코드는 없었다. 

 

__int64 __fastcall VM(__int64 cmd)
{
  __int64 result; // rax
  char v2; // al

  result = *(unsigned __int8 *)(cmd + 48);      // eip
  switch ( (_BYTE)result )
  {
    case 0:
      *(_WORD *)(cmd + 62) = *(_WORD *)(cmd + 52);
      goto LABEL_3;
    case 1:
      *(_WORD *)(cmd + 62) = *(_WORD *)(cmd + 52) + *(_WORD *)(cmd + 54);
      goto LABEL_3;
    case 2:
      *(_WORD *)(cmd + 62) = *(_WORD *)(cmd + 54) * *(_WORD *)(cmd + 52);
      goto LABEL_3;
    case 3:
      *(_WORD *)(cmd + 62) = *(_WORD *)(cmd + 54) ^ *(_WORD *)(cmd + 52);
      goto LABEL_3;
    case 4:
      *(_WORD *)(cmd + 62) = *(_WORD *)(cmd + 52) < *(_WORD *)(cmd + 54);
      goto LABEL_3;
    case 5:
      if ( !*(_WORD *)(cmd + 52) )
        goto LABEL_3;
      result = *(unsigned __int16 *)(cmd + 54);
      *(_BYTE *)(cmd + 46) = 0;
      *(_BYTE *)(cmd + 56) = 0;
      *(_BYTE *)(cmd + 64) = 0;
      *(_WORD *)(cmd + 34) = result;
      return result;
    case 6:
      *(_WORD *)(cmd + 62) = read_((_QWORD *)cmd, *(_WORD *)(cmd + 52), *(_WORD *)(cmd + 54));
      goto LABEL_3;
    case 7:                                     // 7--> write
      *(_WORD *)(cmd + 62) = write(
                               1,
                               (const void *)(*(_QWORD *)cmd + *(unsigned __int16 *)(cmd + 52)),
                               *(unsigned __int16 *)(cmd + 54));
      goto LABEL_3;
    case 8:                                     // have to avoid here
      *(_BYTE *)(cmd + 46) = 0;
      *(_BYTE *)(cmd + 56) = 0;
      *(_BYTE *)(cmd + 64) = 0;
      *(_DWORD *)(cmd + 24) = 0;                // end
      break;
    default:
LABEL_3:
      v2 = *(_BYTE *)(cmd + 49);
      *(_BYTE *)(cmd + 64) = 1;
      *(_BYTE *)(cmd + 58) = v2;
      result = *(unsigned __int16 *)(cmd + 50);
      *(_WORD *)(cmd + 60) = result;
      break;
  }
  return result;
}

 

우선 check 모드를 이용해서 side channel attack이 가능한지 알아보았다. "a"의 수만 증가했을 때와 달리 CODEGATE2020을 입력하면 점점 실행되는 명령어 수가 늘어나는 것을 볼 수 있다. 또한 2바이트가 증가할 때마다 실행되는 코드가 늘어나는 것으로 보아 flag 검증 코드가 2바이트 씩 검증한다고 예측할 수 있다. 

 

 

check 모드에서 알아낸 정보를 이용하여 예측한 VM 내부의 로직은 다음과 같다.  

for x in range(len(flag)/2):
	if input[x*2:x*(2+1)]==flag[2*x:2*(x+1)]:
    	pass
    else:
    	exit(1)

따라서 입력값을 2바이트 씩 바꿔가면서 실행되는 코드 수를 측정하게 템플릿 코드를 바꾸었다.

from subprocess import PIPE, Popen
import string
import sys
import os
import getopt
from threading import Thread


def cmdline(command):
    process = Popen(args=command,stdout=PIPE,shell=True).stdout
#    print(command)
    return process.read()

st=string.printable
st="qwertyuioplkjhgfdsazxcvbnmMNBVCXZASDFGHJKLPOIUYTREWQ1234567890'_{})(*&^%$#@!~`"
count=0
first=0
fisrt_st=""
flag=""

try:
    opts,etc_args= getopt.getopt(sys.argv[1:],"hf:m:l:s:a:",["help","file=","mode=",'len=','start_with=',"argv="])
except getopt.GetoptError:
    print("-f <file> -m <mode> -l <len> -s <start_with> -a <argv for file>")
    exit(1)

i=0
for opt,arg in opts:
    if opt=="-f":
	file_name=arg
    elif opt=="-m":
	mode=arg
    elif opt=="-l":
	le=int(arg)
    elif opt=="-s":
	if(mode=="check"):
	    start=arg
	else:
	    flag+=arg
    elif opt=="-a":
	file_name += " "+arg
	i+=1
if(i==0):
    argv=""


if mode=="len":
    for x in xrange(le):
	cmd="/bin/echo '"+"A"*x+"' | ../../pin -t ../../inscount.so -- ./"+file_name
	count=cmdline(cmd)#.read()
	count=int(count)
	if(first<count):
	    first=count
	    print("x = %d count = %d"%(x+1,count))


if mode=="crack":
    for x in xrange(le):
	for y in xrange(len(st)):
	    print("%dcycle"%y)
	    for z in xrange(len(st)):
		if st[y]=="'" or "'" in flag:
		    count=cmdline('/bin/echo "'+flag+st[y]+st[z]+'" | ../../pin -t ../../inscount.so -- ./'+file_name)
		else:
		    count=cmdline("/bin/echo '"+flag+st[y]+st[z]+"' | ../../pin -t ../../inscount.so -- ./"+file_name)
                if "GOOD!" in count :
                    print(flag + st[y]+st[z])
                    exit(1)
                try:
		    count=int(count)
		except:
		    count=first
		if(first<count):
		    first=count
		    first_st=st[y]+st[z]
		    print("x = %s count = %d"%(st[y]+st[z],count))
	flag+=first_st #in my experience. In this condition st[y] is flag 
        fd=open("flag",'w')
        fd.write(flag)
        fd.close()
	print(flag)
if mode=="check":
    print("check side channel attack")
    for x in xrange(int(len(start))):
	flag+=start[x]
	count=cmdline('/bin/echo "'+flag+'" | ../../pin -t ../../inscount.so -- ./'+file_name)#get target by cmdline
	non=cmdline("/bin/echo '"+"a"*(x+1)+"' | ../../pin -t ../../inscount.so -- ./"+file_name)
        print "NON = %s %s"%("a"*(x+1),non)
        print "flag = %s  %s"%(flag, count)

 

그리고 아래의 커맨드로 flag를 crack 했다. 

 

python solve.py -f ./simple_machine -m crack -l 20 -a ./target -s 'CODEGATE2020'

 

conoha 서버의 제일 싼 서버에서 하루 정도 돌리니 flag가 나왔다. (CPU 3개,RAM 2G) 2바이트 검증이라 시간이 매우 오래 걸린 것 같다.

 

 

 

대회 당시 코드 최적화는 못하고 손으로 프로세스 6씩 돌리고 게싱도 오지게 했었는데, 노가다라도 해서 시간 내에 flag를 얻은 것 같다.


TODO

  1. 쓰레드 이용
  2. 파이썬 코드 구조 수정 
  3. 다양한 경우에 맞출 수 있도록 수정
  4. bash 에러 수정
  5. 전체 코드가 아니라 "cmp"의 숫자만 세게 한다면 실행 시간 단축 가능???