troubleshooting

Security onion: rule 전체 modify를 위한 삽질기

November 02, 202315 min read
Image for Security onion: rule 전체 modify를 위한 삽질기

결론

룰을 sid를 기준으로 관리하며 룰 자체를 전체 수정하는 방식으로 사용하고자 한다면, disable 후 add 시키는 방식을 사용하는 것이 좋을 것 같습니다.

이하는 2.3.xx 버전을 기준으로 테스트한 기록입니다. 개인적으로 얻어간 것도 있지만 결론적으로 목적 달성에 실패한 기록들이니... 궁금하신 분들만 읽어보세요

+

수리카타에서 룰을 관리할 때 작게나마 DB 등을 사용하지 않고 이렇게 파일을 복잡하게 사용하는 이유가 있는걸까요...? sid로 일괄 관리하고 싶었는데 그렇게 하려니 쉽지 않아서 아쉬웠습니다.

상황

Security onion에서의 rule control하는 부분들을 응용하여 무언가 제작할 일이 있었다. Kafka로 rule에 관련된 입력값들을 받은 후 내부적으로 SO(Security onion)을 응용해야 하는데, 기존 SO을 변형하지 않고 있는 기능을 최대한 그대로 사용하는 것이 목표였다. SO가 업데이트 될 때마다 우리가 고친 부분들이 덮어써질 것이기 때문이다.

다른건 몰라도 rule modify 시에 상당히 삽질을 하게 되어서... 기록 남기려고 작성했다.

분석 및 목표설정

Security onion side

Security onion에서 rule을 컨트롤하기 위해서는 so-rule 명령어를 사용한다.

so-rule 실행의 실체는 Security onion이 설치된 리눅스의 /usr/sbin/에 위치한 so-rule이라는 파이썬 파일이다(so-rule python code).

아래는 modify 동작을 최초로 인식하는 부분이다.

so-rule 명령어를 사용해서 modify 내용을 추가하려면 'so-rule modify add [sid] [search_term] [replace_term]'와 같은 방식으로 입력해야 하는 것도 여기에 명시되어 있다.

...
# Modify actions
  modify = subparsers.add_parser('modify')
  modify_sub = modify.add_subparsers()

  modify_add = modify_sub.add_parser('add')
  modify_add.set_defaults(func=add_rem_modify)
  modify_add.add_argument('sid_pattern', metavar='SID|REGEX', help=sid_or_regex_help)
  modify_add.add_argument('search_term', metavar='SEARCH_TERM', help=search_term_help)
  modify_add.add_argument('replace_term', metavar='REPLACE_TERM', help=replace_term_help)
  modify_add.add_argument('--apply', action='store_const', const=True, required=False, help=apply_help)

  modify_rem = modify_sub.add_parser('remove')
  modify_rem.set_defaults(func=add_rem_modify, remove=True)
  modify_rem.add_argument('sid_pattern', metavar='SID', help=sid_or_regex_help)
  modify_rem.add_argument('search_term', metavar='SEARCH_TERM', help=search_term_help)
  modify_rem.add_argument('replace_term', metavar='REPLACE_TERM', help=replace_term_help)
  modify_rem.add_argument('--apply', action='store_const', const=True, required=False, help=apply_help)

  modify_list = modify_sub.add_parser('list')
  modify_list.set_defaults(func=list_modified_rules)

...

func=add_rem_modify를 보아 rule 수정 내용 추가 시에 이 함수를 사용하는 것을 알 수 있다.

아래는 같은 소스코드에 존재하는 add_rem_modify 함수이다.

def add_rem_modify(args: dict):
  global salt_proc

  if not check_sid_pattern(args.sid_pattern):
    return 2

  if not valid_regex(args.search_term):
    print_err('Search term is not a valid regex pattern.')

  string_val = f'{args.sid_pattern} \"{args.search_term}\" \"{args.replace_term}\"'

  pillar_dict = read_pillar(args.pillar)

  if not sids_key_exists(pillar_dict, 'modify'):
    pillar_dict['idstools']['sids']['modify'] = None

  if args.remove:
    temp_pillar_dict = rem_from_sids(pillar_dict, 'modify', string_val)
  else:
    temp_pillar_dict = add_to_sids(pillar_dict, 'modify', string_val)
    
  if temp_pillar_dict['idstools']['sids']['modify'] == pillar_dict['idstools']['sids']['modify']:
    salt_proc = check_apply(args, prompt=False)
    return salt_proc
  else:
    pillar_dict = temp_pillar_dict

  # TODO: Determine if a rule should be removed from disabled if modified.
  if not args.remove:
    if sids_key_exists(pillar_dict, 'disabled'):
      pillar_dict = rem_from_sids(pillar_dict, 'disabled', args.sid_pattern, optional=True) 

  write_pillar(pillar=args.pillar, content=pillar_dict)
  
  salt_proc = check_apply(args)
  return salt_proc
  

sid와 search_term에 대해 regex 문법 체크를 한 후, search_term과 replace_term에 대해 escape 처리된 double quotes로 감싸서 처리되고 있다.

sid로 검색해서 나온 rule 전문에 대해 덮어쓰기를 하는 방식을 기대했지만, 여기까지만 봐도 그런 방식이 아님을 알 수 있다. search_term을 regex로 검색한 후 이를 전부 replace_term으로 교체하는 방식이다. 하지만 여러 문자열이 아닌 짧은 문자열에 대해 교체하고자 할 때, 이와 같은 방식이 유지된다면 사용자가 원하던 것과는 다른 rule 수정이 일어날 수도 있다고 보았다. 짧은 문자열일수록 원하지 않던 곳에 문자열 대체가 일어날 확률이 높아지기 때문이다.

그러나 우리의 목적은 기존의 SO에는 변경을 가하지 않는 것이었기 때문에, search_term에 rule 전문을 잘 우겨넣어 인자로 잘 전 전달하는 방법을 찾아야 했다.

이제부터 이 아래는 그 삽질기이다...

위의 add_rem_modify 함수에서 알게 된 것은 두가지이다.

  1. double quotes(")를 escape처리 해야 search_term이 한 덩어리의 string으로 온전하게 들어감(rule의 msg 부분에 거의 대부분 double quotes(")가 들어가기 때문에, msg 부분에서 문자열이 잘릴 수도 있다)
  2. regex escape처리가 되어야 중간에 에러가 걸리지 않음

그래서 대강 이런 식의 escape 처리 후 command 입력을 시도했다.

...
import re
...

# Kafka에서 참조한 sid, new_rule_raw / DB에서 참조해 온 old_rule_raw
old_rule = re.escape(old_rule_raw.replace('"', '\\"'))
new_rule = re.escape(new_rule_raw.replace('"', '\\"'))

command_list = [
  f"so-rule modify add {sid} '{old_rule}' '{new_rule}' --apply"
]
...

두 escape 처리 이전까지 뜨던 각종 bash 오류들(ex. unexpected token)이 사라져 순조롭게 진행되는 듯 했으나, stdout으로 또다른 문제에 직면했음을 알 수 있었다.

idstools(suricata) side

 Configuration updated. Applying changes:, 
 Syncing config files..., 
 Updating rules..., 
 2023-11-01 06:59:13,009 - <INFO> - Loading ./rulecat.conf., 
 2023-11-01 06:59:13,012 - <INFO> - Forcing Suricata version to 6.0., 
 Traceback (most recent call last):, 
   File "/usr/local/bin/idstools-rulecat", line 12, in <module>, 
     sys.exit(main()), 
   File "/usr/local/lib/python3.9/site-packages/idstools/scripts/rulecat.py", line 841, in main, 
     modify_filters += load_filters(args.modify), 
   File "/usr/local/lib/python3.9/site-packages/idstools/scripts/rulecat.py", line 375, in load_filters, 
     filter = ModifyRuleFilter.parse(line), 
   File "/usr/local/lib/python3.9/site-packages/idstools/scripts/rulecat.py", line 218, in parse, 
     raise Exception("Bad number of arguments."), 
 Exception: Bad number of arguments.

so-rule을 실행하는 데 까지 생기는 문제는 해결한 것 같다. 하지만 이제는 명령어 실행 중 내부에서 문제가 생기는 것 같다.

manager sls파일(/opt/so/saltstack/local/pillar/minion/[host_manager].sls) modify 항목에 잘 입력된 것을 확인했다. 즉 modify 과정은 2 step 이었던 것이다.

  1. manager 파일에 rule의 sid와 함께 modify 내역을 기록
  2. 이 기록을 바탕으로 실제 rule modify 진행

1까지는 성공적이었으나 2 과정 중에서 문제가 생기는 것으로 파악되었다.

위의 문제를 해결하기 위해 rulecat.py를 뜯어보았다.

다음은 문제가 되는 ModifyRuleFilter.parse()를 관찰하기 위해 가져온 class이다.

rulecat.py line 218 확인

class ModifyRuleFilter(object):
    """Filter to modify an idstools rule object.

    Important note: This filter does not modify the rule inplace, but
    instead returns a new rule object with the modification.
    """

    def __init__(self, matcher, pattern, repl):
        self.matcher = matcher
        self.pattern = pattern
        self.repl = repl

    def match(self, rule):
        return self.matcher.match(rule)

    def filter(self, rule):
        modified_rule = self.pattern.sub(self.repl, rule.format())
        parsed = idstools.rule.parse(modified_rule, rule.group)
        if parsed is None:
            logger.error("Modification of rule %s results in invalid rule: %s",
                         rule.idstr, modified_rule)
            return rule
        return parsed

    @classmethod
    def parse(cls, buf):
        tokens = shlex.split(buf)
        if len(tokens) == 3:
            matchstring, a, b = tokens
        elif len(tokens) > 3 and tokens[0] == "modifysid":
            matchstring, a, b = tokens[1], tokens[2], tokens[4]
        else:
            raise Exception("Bad number of arguments.")
        matcher = parse_rule_match(matchstring)
        if not matcher:
            raise Exception("Bad match string: %s" % (tokens[0]))
        pattern = re.compile(a)

        # Convert Oinkmaster backticks to Python.
        b = re.sub("\$\{(\d+)\}", "\\\\\\1", b)

        return cls(matcher, pattern, b)

여기서 위에서 본 "Bad number of arguments" 문구를 확인할 수 있었고 이의 원인이 무엇인지도 파악할 수 있었다.

shlex.split(buf) 결과물의 갯수가 기준과 맞지 않기 때문이다.

buf로 입력되는 것은 역시 위의 manager sls파일에서 읽은 modify 항목들이다.

(rulcat.py line 375 확인)

def load_filters(filename):

    filters = []

    with open(filename) as fileobj:
        for line in fileobj:
            line = line.strip()
            if not line or line.startswith("#"):
                continue
            filter = ModifyRuleFilter.parse(line)
            if filter:
                filters.append(filter)
            else:
                log.error("Failed to parse modify filter: %s" % (line))

    return filters

manager sls을 한줄씩 읽어와 buf로 입력하고 있다.

이를 확인 후 manager sls파일에서 자동으로 modify 항목으로 입력된 값을 확인했더니, 한 row로 입력된 것이 아니라 부분부분 줄바꿈처리 되어 들어가 있었다.

(테스트한 rule은 suricata ET open rule중 하나를 가져왔다.)

...
idstools:
  ...
  sids:
    disabled:
    - '0000001'
    - '0000002'
    enabled:
    - '0000003'
    - '0000004'
    modify:
    - '0000005 "alert ip \[1.117.107.241,1.117.113.235,1.117.142.123,1.117.155.76,1.117.233.118,1.117.87.94,1.12.220.225,1.13.5.215,1.14.101.60,1.14.162.37,1.14.170.161,1.14.63.99,1.14.72.158,1.15.112.113,1.15.119.209,1.15.155.17,1.15.48.27,1.15.94.178,1.212.197.134,1.234.80.51,1.234.80.65,1.6.52.114,1.9.78.242,101.176.32.93,101.32.127.191,101.32.141.93,101.33.232.244,101.34.0.215,101.34.103.153,101.34.211.195,101.34.246.169,101.34.67.139,101.34.69.51,101.35.181.230,101.35.19.119,101.35.219.249,101.35.232.12,101.35.252.124,101.35.255.83,101.35.98.237,101.42.0.60,101.42.154.35,101.42.234.70,101.42.237.207,101.42.47.78,101.43.155.178,101.43.19.142,101.43.226.18,101.43.5.247,101.43.67.29\]
      any ->  any \(msg:"ET 3CORESec Poor Reputation IP group 1"; reference:url,blacklist.3coresec.net/lists/et-open.txt;
      threshold: type limit, track by_src, seconds 3600, count 1; classtype:misc-attack;
      sid:0000005; rev:855; metadata:affected_product Any, attack_target Any, deployment
      Perimeter, tag 3CORESec, signature_severity Major, created_at 2020_07_20, updated_at
      2023_10_30;\)" "alert ip \[1.117.107.241,1.117.113.235,1.117.142.123,1.117.155.76,1.117.233.118,1.117.87.94,1.12.220.225,1.13.5.215,1.14.101.60,1.14.162.37,1.14.170.161,1.14.63.99,1.14.72.158,1.15.112.113,1.15.119.209,1.15.155.17,1.15.48.27,1.15.94.178,1.212.197.134,1.234.80.51,1.234.80.65,1.6.52.114,1.9.78.242,101.176.32.93,101.32.127.191,101.32.141.93,101.33.232.244,101.34.0.215,101.34.103.153,101.34.211.195,101.34.246.169,101.34.67.139,101.34.69.51,101.35.181.230,101.35.19.119,101.35.219.249,101.35.232.12,101.35.252.124,101.35.255.83,101.35.98.237,101.42.0.60,101.42.154.35,101.42.234.70,101.42.237.207,101.42.47.78,101.43.155.178,101.43.19.142,101.43.226.18,101.43.5.247,101.43.67.29\]
      any ->  any \(msg:"###LINE FEED TEST### ET 3CORESec Poor Reputation IP group
      1"; reference:url,blacklist.3coresec.net/lists/et-open.txt; threshold: type
      limit, track by_src, seconds 3600, count 1; classtype:misc-attack; sid:0000005;
      rev:855; metadata:affected_product Any, attack_target Any, deployment Perimeter,
      tag 3CORESec, signature_severity Major, created_at 2020_07_20, updated_at 2023_10_30;\)"'
    - '0000006'...
...

이전 과정에서 re.escape(str_raw.replace('"', '\\"'))로 escape 처리한 이후 줄바꿈 취급되는 곳이 있는지 확인해야 했다. 그렇다면, so-rule modify 명령어를 통해 manager sls파일에 쓰기를 진행하는 곳을 찾아야 했다. 다시, rulecat.py를 실행하기 전인 so-rule를 분석해야 한다.

분석 결과... string 처리 중에는 개행으로 대체되는 곳이 없었고, 다른 부분에서 개행이 자동으로 들어갈 만한 구석이 있는지 찾아야만 했다. 그렇다면 파일 쓰기를 할 대 default로 개행이 들어가는지를 확인해야 하는데, manager sls 파일에 유일하게 쓰기를 하는 부분은 이곳이었다.

so-rule line 108 참고

def write_pillar(pillar: str, content: dict):
  try:
    sids = content['idstools']['sids']
    if sids['disabled'] is not None:
      if len(sids['disabled']) == 0: sids['disabled'] = None
    if sids['enabled'] is not None:
      if len(sids['enabled']) == 0: sids['enabled'] = None
    if sids['modify'] is not None:
      if len(sids['modify']) == 0: sids['modify'] = None

    with open(pillar, 'w') as f:
      return yaml.dump(content, f, default_flow_style=False)
  except Exception as e:
    print_err(f'Could not open {pillar}')
    sys.exit(3)

yaml.dump()so-rule에서 유일하게 파일을 쓰는 부분이다. yaml.dump() 이 함수에 대해 별도의 테스트를 진행해 보았더니, 너무 긴 string이 들어오면 자동으로 개행을 하는 옵션이 있었고, 무려 이것이 default였다. yaml.dump()에 개행하지 않는 옵션을 추가할 수는 있겠으나, 이는 즉 SO 소스에 변경이 불가피한 것이었다.

중간결론

sid로 검색해서 나온 rule 전문에 대해 덮어쓰기를 하는 방식을 SO 소스코드 변경 없이 구현하기 위해서는, manager sls 파일에 escape 처리된 rule을 직접 입력하는 것이 최선이다.

중간 해결책

so-rule 코드를 일부 차용한 후 필요한 부분을 고치는 방법을 썼다.

수정이 필요한 부분은 사실상 한 줄이다.

yaml.dump(content, f, default_flow_style=False)

개행을 방지하기 위해 다음과 같은 옵션들을 줄 수 있다.

  1. default_style 설정

    yaml.dump(content, f, default_flow_style=False, default_style='|')
  2. width 설정

    yaml.dump(content, f, default_flow_style=False, width=1000)

1은 검색했을 때 가장 많이 권고되는 방식이었다(참고한 stackoverflow 답변). 그러나 이런 방식으로 rule을 입력하면 단점이 있었다.

...
idstools:
  ...
  sids:
    disabled:
    - '0000001'
    - '0000002'
    enabled:
    - '0000003'
    - '0000004'
    modify:
    - |-
      0000005 "alert\ ip\ \[1\.117\.107\.241,1\.117\.113\.235,1\.117\.142\.123,1\.117\.155\.76,1\.117\.233\.118,1\.117\.87\.94,1\.12\.220\.225,1\.13\.5\.215,1\.14\.101\.60,1\.14\.162\.37,1\.14\.170\.161,1\.14\.63\.99,1\.14\.72\.158,1\.15\.112\.113,1\.15\.119\.209,1\.15\.155\.17,1\.15\.48\.27,1\.15\.94\.178,1\.212\.197\.134,1\.234\.80\.51,1\.234\.80\.65,1\.6\.52\.114,1\.9\.78\.242,101\.176\.32\.93,101\.32\.127\.191,101\.32\.141\.93,101\.33\.232\.244,101\.34\.0\.215,101\.34\.103\.153,101\.34\.211\.195,101\.34\.246\.169,101\.34\.67\.139,101\.34\.69\.51,101\.35\.181\.230,101\.35\.19\.119,101\.35\.219\.249,101\.35\.232\.12,101\.35\.252\.124,101\.35\.255\.83,101\.35\.98\.237,101\.42\.0\.60,101\.42\.154\.35,101\.42\.234\.70,101\.42\.237\.207,101\.42\.47\.78,101\.43\.155\.178,101\.43\.19\.142,101\.43\.226\.18,101\.43\.5\.247,101\.43\.67\.29\]\ any\ \->\ \$HOME_NET\ any\ \(msg:\\"ET\ 3CORESec\ Poor\ Reputation\ IP\ group\ 1\\";\ reference:url,blacklist\.3coresec\.net/lists/et\-open\.txt;\ threshold:\ type\ limit,\ track\ by_src,\ seconds\ 3600,\ count\ 1;\ classtype:misc\-attack;\ sid:0000005;\ rev:855;\ metadata:affected_product\ Any,\ attack_target\ Any,\ deployment\ Perimeter,\ tag\ 3CORESec,\ signature_severity\ Major,\ created_at\ 2020_07_20,\ updated_at\ 2023_10_30;\)"

rule이 한줄로 처리되기는 하나 시작 지점에 |-와 함께 한번 줄바꿈처리 된다는 것이었다. rulecat.py로 넘어가서는 sid 하나당 단 한줄을 읽을 것이기 떄문에 이 방법이 나에게는 문제가 되었다.

두번째 방법을 택하게 되었는데, 문제는 width를 얼마로 설정하느냐였다. rule이 상당히 길기 때문에 보통의 경우 1000자가 넘어갔다. 이는 suricata의 rule max length(=8192)를 조사하여 반영하였다.

yaml.dump(content, f, default_flow_style=False, width=8192)

삽질 결과

위의 과정 모두가 어떻게 보면 suricata 내부적으로 룰 관리 커맨드 입력 시 이를 핸들링하기 위해 split하는 문자열을 입맛에 맞게 우회하기 위함이었다(ex. double quotes). 그런데 모든 방식의 우회 결과 결론은 아래와 같다.

  1. double quotes는 당연히 우회해야 함. 그런데 double quotes만 우회시키면 bad arguments number가 뜸

  2. 룰을 한 덩어리로 인식하지 못하는 문제로 보여, 추가적인 특수문자 우회를 함(re.escape 등. white space만 우회해 보기도 했다.)

  3. 이런 후처리까지 할 경우, 별도의 오류는 생기지 않으나 우회된 룰 문자열을 인식하지 못함.

룰 전체를 인식시키고 그 전부를 대체시키고자 했으나, 룰 전체 인식이 불가해졌습니다.

이후엔 편리하게 룰 disable 시키고 다시 등록시키는 방식으로 변경했습니다.