腳本程式碼混淆-Python篇-pyminifier(2)

  • 2019 年 11 月 6 日
  • 筆記

微信公眾號:七夜安全部落格 關注資訊安全技術、關注 系統底層原理。問題或建議,請公眾號留言。

前言

上文中,我們講解了pyminifier中簡化和壓縮程式碼的功能。本篇作為第二篇,也是最終篇,講解一下最重要的功能:程式碼混淆,學習一下這個項目的混淆策略。大家如果覺得不錯的話,一定要分享到朋友圈哈,寫了快5000字,基本上每一個細節都給大家拆分出來了,貼了一部分關鍵程式碼,會長一些,一定要有耐心喲。

一.混淆效果

在講解混淆策略之前,先看一下混淆的效果,惡不噁心,哈哈。對比著混淆的結果,再結合我的講解,會理解地更加深入。

原始程式碼

專門設計了一段程式碼,基本上涵蓋了經常出現的語法內容。

import io  import tokenize    abvcddfdf = int("10")  def enumerate_keyword_args(tokens=None):      keyword_args = {}      inside_function = False      dsfdsf,flag = inside_function,1      a = str(flag)      for index, tok in enumerate(tokens):          token_type = tok[0]          token_string = tok[1]          a = str(token_string)          b=a          if token_type == tokenize.NAME:              if token_string == "def":                  function_name = tokens[index+1][1]                  keyword_args.update({function_name: []})              elif inside_function:                  if tokens[index+1][1] == '=': # keyword argument                      print(api(text=token_string))                      keyword_args[function_name].append(token_string)  def api(text):      print(text)    def listified_tokenizer(source):      io_obj = io.StringIO(source)      return [list(a) for a in tokenize.generate_tokens(io_obj.readline)]    code = u'''  def api(text):      print(text)  '''  abcd=1212  bcde=abcd  cdef=(abcd,bcde)  defg=[abcd,bcde,cdef]  efgh = {abcd:"cvcv","b":"12121"}  f12212="hhah"  f112122="hheeereah"  tokens_list = listified_tokenizer(code)  print(tokens_list)  enumerate_keyword_args(tokens_list)

混淆後的效果

#!/usr/bin/env python  #-*- coding:utf-8 -*-  흞=int  ݽ=None  ﮄ=False  ﻟ=str  嬯=enumerate  눅=list  import io  ﭢ=io.StringIO  import tokenize  ﰅ=tokenize.generate_tokens  ނ=tokenize.NAME    嘢 = 흞("10")  def ܪ(tokens=ݽ):      蘩 = {}      ﭷ = ﮄ      dsfdsf,flag = ﭷ,1      a = ﻟ(flag)      for ﶨ, tok in 嬯(tokens):          ﶗ = tok[0]          ﯢ = tok[1]          a = ﻟ(ﯢ)          b=a          if ﶗ == ނ:              if ﯢ == "def":                  齬 = tokens[ﶨ+1][1]                  蘩.update({齬: []})              elif ﭷ:                  if tokens[ﶨ+1][1] == '=': # keyword argument                      print(ݖ(text=ﯢ))                      蘩[齬].append(ﯢ)  def ݖ(ﲖ):      print(ﲖ)    def ﰭ(source):      د = ﭢ(source)      return [눅(a) for a in ﰅ(د.readline)]    ﳵ = u'''  def api(text):      print(text)  '''  횗=1212  ﮪ=횗  ﲊ=(횗,ﮪ)  딲=[횗,ﮪ,ﲊ]  ࢹ = {횗:"cvcv","b":"12121"}  ﮤ="hhah"  ﱄ="hheeereah"  狾 = ﰭ(ﳵ)  print(狾)  ܪ(狾)

二.混淆策略

pyminifier的混淆策略分成五大部分,主要是針對變數名,函數名,類名,內置模組名和外部模組進行混淆。每種混淆又分成兩步,第一步是確定要混淆的內容,第二步進行內容替換,替換成隨機字元。

1.變數名混淆

針對變數名的混淆,並不是所有變數名都能混淆的,因為要保證安全性,混淆過頭了,程式就無法運行了。在 函數obfuscatable_variable對變數名進行了過濾,保留著可以混淆的變數名。

def obfuscatable_variable(tokens, index, ignore_length=False):        tok = tokens[index]      token_type = tok[0]      token_string = tok[1]      line = tok[4]      if index > 0:          prev_tok = tokens[index-1]#獲取上一個Token      else: # Pretend it's a newline (for simplicity)          prev_tok = (54, 'n', (1, 1), (1, 2), '#n')      prev_tok_type = prev_tok[0]      prev_tok_string = prev_tok[1]      try:          next_tok = tokens[index+1]#獲取下一個Token      except IndexError: # Pretend it's a newline          next_tok = (54, 'n', (1, 1), (1, 2), '#n')      next_tok_string = next_tok[1]      if token_string == "=":# 跳過賦值 = 後面的token          return '__skipline__'      if token_type != tokenize.NAME:#不是變數名稱忽略          return None # Skip this token      if token_string.startswith('__'):## __ 開頭的不管,比如__init__          return None      if next_tok_string == ".":# 導入的模組名(已經導入的)忽略          if token_string in imported_modules:              return None      if prev_tok_string == 'import':#導入的包名忽略          return '__skipline__'      if prev_tok_string == ".":#導入模組中的變數/函數忽略          return '__skipnext__'      if prev_tok_string == "for":#for循環中的變數如果長度大於2則進行混淆          if len(token_string) > 2:              return token_string      if token_string == "for":# for 關鍵字忽略          return None      if token_string in keyword_args.keys():#函數名忽略          return None      if token_string in ["def", "class", 'if', 'elif', 'import']:#關鍵字忽略          return '__skipline__'      if prev_tok_type != tokenize.INDENT and next_tok_string != '=':          return '__skipline__'      if not ignore_length:          if len(token_string) < 3:#長度小於3個則忽略              return None      if token_string in RESERVED_WORDS:#在保留字中也忽略          return None      return token_string

從函數中可以看到,有以下幾類變數名不能混淆:

  1. token屬性不是tokenize.NAME的過濾掉,例如數字token,字元串token,符號token。
  2. 以 __ 開頭的名稱過濾掉,例如 init
  3. 導入的第三方的模組名和變數過濾掉,例如 import os,os不能修改。
  4. for循環中的變數名長度小於等於2的過濾掉。
  5. 函數名過濾掉(接下來會有專門針對函數的處理方式)。
  6. 關鍵字和保留字過濾掉,長度小於3的名稱也過濾掉。

確定了要混淆的內容,接下來進行替換,主要涉及replace_obfuscatablesobfuscate_variable函數,核心程式碼如下:

if token_string == replace and prev_tok_string != '.':# 不是導入的變數          if inside_function:#在函數裡面              if token_string not in keyword_args[inside_function]:#判斷是否在參數列表中                  if not right_of_equal: #token所在的這一行沒有 = 或者token在 = 的左邊                      if not inside_parens: # token不在( )之間                          return return_replacement(replacement) # 例如 a=123 ,str.length() 中的str                      else:                          if next_tok[1] != '=':# token在( )之間  api(text) 中的 text,                              return return_replacement(replacement)                  elif not inside_parens:#token在 = 的右邊,token不在( )之間   例如 a = b 中的b                      return return_replacement(replacement)                  else:#token在 = 的右邊,token在( )之間                      if next_tok[1] != '=': #例如a=api(text) text需要改變                          return return_replacement(replacement)          elif not right_of_equal:#token所在的這一行沒有 = 或者token在 = 的左邊              if not inside_parens:                  return return_replacement(replacement)              else:                  if next_tok[1] != '=':                      return return_replacement(replacement)          elif right_of_equal and not inside_parens:# 例如 a = b 中的b              return return_replacement(replacement)

在上述程式碼中可以看出,混淆變數名稱需要區分作用域,即模組中的變數和函數中的變數,即使名稱是一樣的,但不是一回事,所以需要區分對待。通過如下三個變數進行劃分:

  • inside_function 代表變數是在函數中
  • right_of_equal 代表著變數是在 = 的右側
  • inside_parens 代表變數是在()中

大家可能奇怪,right_of_equal 和 inside_parens 是用來幹什麼的?其實是為了區分函數調用使用參數名的情況。例如:

def api(text):      print(text)    api(text="123")

在函數調用的時候, api(text="123")中的text是不能混淆的,不然會報錯的。

2.函數名混淆

通過obfuscatable_function函數確定要混淆的函數名稱,原理上很簡單,排除類似_init_的函數,然後前一個token是def,那當前的token就是函數名稱。

def obfuscatable_function(tokens, index, **kwargs):       ......      prev_tok_string = prev_tok[1]      if token_type != tokenize.NAME:          return None # Skip this token      if token_string.startswith('__'): # Don't mess with specials          return None      if prev_tok_string == "def": #獲取函數名稱          return token_string

對於函數名稱的替換主要是在兩個部位,一個是函數定義的時候,另一個是在函數調用的時候。函數定義的時候容易確定,函數調用的時候大體分成兩種情況,一種是靜態函數,另一種是動態函數,主要是要確認一下是否需要替換。具體程式碼位於obfuscate_function函數中:

def obfuscate_function(tokens, index, replace, replacement, *args):        def return_replacement(replacement):          FUNC_REPLACEMENTS[replacement] = replace          return replacement            ......      if token_string.startswith('__'):          return None      if token_string == replace:          if prev_tok_string != '.':              if token_string == replace: #函數定義                  return return_replacement(replacement)          else:#函數調用              parent_name = tokens[index-2][1]              if parent_name in CLASS_REPLACEMENTS:#classmethod                  # This should work for @classmethod methods                  return return_replacement(replacement)              elif parent_name in VAR_REPLACEMENTS:#實例函數                  # This covers regular ol' instance methods                  return return_replacement(replacement)

在程式碼的末尾 通過prev_tok_string來判斷是定義函數還是調用,如果prev_tok_string!=「.」,代表著定義。

通過parent_name是否在CLASS_REPLACEMENTS VAR_REPLACEMENTS中,判斷是靜態函數還是動態函數,但是寫的有點冗餘,最後的處理方式都是一樣的。

3.類名混淆

通過obfuscatable_class函數來確認要混淆的類名稱,只要判斷 prev_tok_string=="class" 即可。

def obfuscatable_class(tokens, index, **kwargs):      ......      prev_tok_string = prev_tok[1]      if token_type != tokenize.NAME:          return None # Skip this token      if token_string.startswith('__'): # Don't mess with specials          return None  #通過判斷前一個token是class,就可以知道當前的是類名稱      if prev_tok_string == "class":          return token_string

對於類名稱的替換,這個項目進行了簡化處理,無法跨模組跨文件進行混淆,這樣的設定就簡單了很多,關鍵程式碼在obfuscate_class函數中,其實直接就替換了,沒啥複雜的。

def obfuscate_class(tokens, index, replace, replacement, *args):        def return_replacement(replacement):          CLASS_REPLACEMENTS[replacement] = replace          return replacement      ......      if prev_tok_string != '.': ##無法跨模組混淆          if token_string == replace:              return return_replacement(replacement)

4.builtin模組混淆

首先遍歷token發現內置模組中的函數和類,程式碼中內置了 builtins表,enumerate_builtins函數通過比對裡面的值來確定token是否是內置的。

builtins = [      'ArithmeticError',      'AssertionError',      'AttributeError',      ......      'str',      'sum',      'super',      'tuple',      'type',      'unichr',      'unicode',      'vars',      'xrange',      'zip'  ]

內置模組的混淆通過賦值的方式來實現,舉個例子,在Python 中有個str的內置函數,正常程式碼如下:

sum = str(10)

混淆後:

xxxx= str  sum = xxxx(19)

原理如上所示,具體是通過obfuscate_builtins函數來實現的,將所有符合的內置函數/類,都轉化成賦值等式,插入到token鏈的前面,但是有一點需要注意:新的token必須要放到解釋器路徑(#!/usr/bin/env python)和編碼('# — coding: utf-8 –')之後,這樣才不會報錯。程式碼如下:

for tok in tokens[0:4]: # Will always be in the first four tokens          line = tok[4]          if analyze.shebang.match(line): # (e.g. '#!/usr/bin/env python')              if not matched_shebang:                  matched_shebang = True                  skip_tokens += 1          elif analyze.encoding.match(line): # (e.g. '# -*- coding: utf-8 -*-')              if not matched_encoding:                  matched_encoding = True                  skip_tokens += 1      insert_in_next_line(tokens, skip_tokens, obfuscated_assignments)

5.第三方模組與函數的混淆

針對第三方模組與函數的混淆,pyminifier進行了簡化處理,具體邏輯在obfuscate_global_import_methods中,通過以下兩種方式導入的模組忽略:

import xxx as ppp  from xxx import ppp

只處理 importpackage類型的導入。

枚舉模組

首先通過 enumerate_global_imports 函數枚舉所有通過import導入的模組,忽略了類裡面和函數中導入的模組,只接受全局導入,核心程式碼如下:

elif token_type == tokenize.NAME:              if token_string in ["def", "class"]:                  function_count += 1              if indentation == function_count - 1: #出了函數之後才會相等                  function_count -= 1              elif function_count >= indentation: #排除了在函數內部和類內部的import導入                  if token_string == "import":                      import_line = True                  elif token_string == "from":                      from_import = True                  elif import_line:                      if token_type == tokenize.NAME  and tokens[index+1][1] != 'as':# 排除 import as                          if not from_import and token_string not in reserved_words:#排除from import                              if token_string not in imported_modules:                                  if tokens[index+1][1] == '.': # module.module                                      parent_module = token_string + '.'                                  else:                                      if parent_module:                                          module_string = (                                              parent_module + token_string)                                          imported_modules.append(module_string)                                          parent_module = ''                                      else:                                          imported_modules.append(token_string)

遍歷函數並混淆

獲取導入的模組後,接著遍歷token,獲取源文件中模組調用的函數,和之前的方法一樣通過賦值的方式進行替換,舉個例子:原程式碼:

import os  os.path.exists("text")

混淆後的程式碼:

import os  ﳀ=os.path  ﳀ.exists("text")

具體函數調用的替換程式碼很簡短,module_method形如os.path,即ﳀ.exists("text")這部分:

if token_string == module_method.split('.')[0]:      if tokens[index+1][1] == '.':          if tokens[index+2][1] == module_method.split('.')[1]:              tokens[index][1] = replacement_dict[module_method]              tokens[index+1][1] = ""              tokens[index+2][1] = ""

接下來將替換變數進行定義,形如ﳀ=os.path,並通過insert_in_next_line函數插入到import模組的下方。有一點需要注意的是token索引index + 6,原因很簡單, ﳀ=os.pathn轉化為token的長度就是6。

elif import_line:      if token_string == module_method.split('.')[0]:          # Insert the obfuscation assignment after the import          ......          else:              line = "%s=%sn" % ( # This ends up being 6 tokens                  replacement_dict[module_method], module_method)          for indent in indents: # Fix indentation              line = "%s%s" % (indent[1], line)              index += 1          insert_in_next_line(tokens, index, line)          index += 6 # To make up for the six tokens we inserted  index += 1

混淆源生成

從上面講解的混淆策略中,我們大體了解了pyminifier的工作方式,但是還有一點沒有講解,那就是混淆源的生成,什麼意思呢?如下所示, os.path為啥會被替換成

ﳀ=os.path

混淆源生成位於obfuscation_machine函數中,分成了兩種情況。

在Py3中,支援unicode字元作為變數名稱,所以基本上是使用unicode字元作為數據源,混淆後會出現各個國家的語言符號,看著著實噁心,而Py2則是使用的ASCII碼的大小寫作為數據源。數據源有了,然後進行隨機化,讓其變得更混亂一些。

程式碼如下:

# This generates a list of the letters a-z:      lowercase = list(map(chr, range(97, 123)))      # Same thing but ALL CAPS:      uppercase = list(map(chr, range(65, 90)))      if use_unicode:          # Python 3 lets us have some *real* fun:          allowed_categories = ('LC', 'Ll', 'Lu', 'Lo', 'Lu')          # All the fun characters start at 1580 (hehe):          big_list = list(map(chr, range(1580, HIGHEST_UNICODE)))          max_chars = 1000 # Ought to be enough for anybody :)          combined = []          rtl_categories = ('AL', 'R') # AL == Arabic, R == Any right-to-left          last_orientation = 'L'       # L = Any left-to-right          # Find a good mix of left-to-right and right-to-left characters          while len(combined) < max_chars:              char = choice(big_list)              if unicodedata.category(char) in allowed_categories:                  orientation = unicodedata.bidirectional(char)                  if last_orientation in rtl_categories:                      if orientation not in rtl_categories:                          combined.append(char)                  else:                      if orientation in rtl_categories:                          combined.append(char)                  last_orientation = orientation      else:          combined = lowercase + uppercase      shuffle(combined) # Randomize it all to keep things interesting

數據源有了,那按照什麼順序輸出呢?

這就用到了permutations 函數,生成迭代器,對數據進行排列組合然後輸出。

for perm in permutations(combined, identifier_length):              perm = "".join(perm)              if perm not in RESERVED_WORDS: # Can't replace reserved words                  yield perm

總結

pyminifier 算是一個不錯的入門項目,幫助大家學習腳本混淆,但是不要用在生產環境中,bug挺多,而且混淆能力並不是很強。接下來我會接著講解腳本混淆的技術手段,不限於python。