ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Python으로 Blackboard 서비스 파싱기
    Python/web scraping 2019. 1. 26. 15:33

    이 글은 2018.05.15에 github pages를 이용해 만들어진 블로그에서 작성 된 글입니다


    이 글에서 개인정보유출 또는 보안상 문제가 될 우려가 있는 부분은 xxx나 … 또는 ~ 등을 이용하여 임의로 삭제되어있습니다.

    저번 글에 이어서 파싱 관련 글만 계속 쓰는 것 같다.

    이번에는 교내에서 과제제출, 과목공지, 수업자료 업로드 등의 목적으로 사용되는 Blackboard 서비스에서 과목 데이터를 받아오기 위해 파싱을 진행했다.

    아래 로그인 화면을 거치면

    아래와 같이 과목 목록이 있는 페이지로 이동한다

    현재 수강중인 과목의 정보를 가져오려면 먼저 로그인을 해야하기 때문에 크롬 개발자 도구를 이용해서 form의 구조와 어디로 post data를 전달하는지(form tag의 action 속성)를 알아봤다.

    user_id로는 학번이 들어가고 password로는 패스워드가 들어가기 때문에 채워서 requests.post를 호출해보면

    1
    2
    3
    4
    5
    6
    7
    8
    import requests
    data = {
    'user_id': '2015xxxx',
    'password': 'xxxxxxxxxxxxx',
    }
    res = requests.post('http://xxx.xxx.ac.kr/webapps/login/', data=data)
    with open('test.html', 'w') as f:
    f.write(res.content.decode())

    당연하게도 실패한다

    위 html 코드를 보면 알겠지만 form에 onsubmit 속성이 존재하고, submit 되기 전에 validate_form function이 호출되는 것을 볼 수 있다. validate_form이 뭔지 확인해보자.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    function validate_form( form, useChallengeResponse, skipEncoding )
    {
    form.user_id.value = form.user_id.value.replace( /^\s*|\s*$/g, "" );
    if ( form.user_id.value == "" || form.password.value == "" )
    {
    alert( JS_RESOURCES.getString('validate.login.invalid.username.or.pass') );
    return false;
    }

    if ( !skipEncoding ) // Only challenge-response and b64 for legacy auth
    {
    if ( useChallengeResponse )
    {
    return validate_form_with_challenge( form );
    }
    else
    {
    return validate_form_no_challenge( form );
    }
    }
    }

    !skipEncoding 값과 useChallengeResponse param 값에 의해서 어떻게 처리될지 결정이 된다. 위 html 코드에서 onsubmit 속성을 보면

    1
    2
    3
    4
    <form onsubmit="return validate_form(this, false, false);"
    method="POST"
    action=".../webapps/login"
    name="login">

    아래와 같이 param이 설정 되어서 함수가 실행되는 것을 알 수 있다.

    1
    2
    skipEncoding=false
    useChallengeResponse=false

    두 변수를 이용하는 곳 중 skipEncoding은 항상 false여야 하므로 (true이면 우리가 requests로 보냈었던 요청과 다르지 않으므로) useChallengeResponse가 false일 경우인 validate_form_no_challange(form); 부분을 확인해보도록 하자.

    1
    2
    3
    4
    5
    6
    7
    function validate_form_no_challenge( form )
    {
    form.encoded_pw.value = base64encode( form.password.value );
    form.encoded_pw_unicode.value = b64_unicode( form.password.value );
    form.password.value = "";
    return true;
    }

    그리고 여기에서 사용되는 base64encode(), b64_unicode() 함수는 아래와 같다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // Converts the input string into a base64 string
    function base64encode(str)
    {
    // Break up into 3 character quantums and convert
    var i, nFrom, nTo;
    var sRet = "";
    for (i=0; i<str.length; i+=3)
    {
    nFrom = i;
    nTo = (i+3 < str.length)? i+3 : str.length;
    sRet += base64encode_quantum(str.substring(nFrom,nTo));
    }
    return sRet;
    }
    1
    2
    3
    function b64_unicode(s) {
    return binl2b64(str2binl(s), s.length * chrsz);
    }

    이제 우리는 두 함수를 파이썬에서 사용할 수 있도록 포팅을 할것이다.

    먼저 base64encode() 함수를 파이썬에서 쓸 수 있도록 처리해보자. 파이썬에는 기본적으로 base64 모듈이 존재하고, 여기에 b64encode() 함수를 사용하면 base64 인코딩을 할 수 있다. 이 함수를 사용하는 것과 같은 결과가 나오는지 확인해보자

    1
    2
    3
    >>> from base64 import b64encode
    >>> b64encode('test string'.encode())
    b'dGVzdCBzdHJpbmc='

    위 사진은 chrome console에서 실행한 결과이고, 아래 코드는 Python3.6 IDLE에서 실행한 결과이다. 같은 결과가 나오므로 우리는 이 함수를 파이썬으로 다시 구현하는 대신에 b64encode() 함수로 대체할 수 있다.

    b64_unicode() 함수는 파이썬에서 별도로 지원하지 않으므로 직접 파이썬에서 실행할 수 있는 코드로 포팅해야한다. b64_encode() 함수에서는 binl2b64()와 str2binl() 두개의 함수를 내부에서 사용하므로 이 두 함수에 대해 알아보자

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    function binl2b64(binarray)
    {
    var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    var str = "";
    for(var i = 0; i < binarray.length * 4; i += 3)
    {
    var triplet = (((binarray[i >> 2] >> 8 * ( i %4)) & 0xFF) << 16)
    | (((binarray[i+1 >> 2] >> 8 * ((i+1)%4)) & 0xFF) << 8 )
    | ((binarray[i+2 >> 2] >> 8 * ((i+2)%4)) & 0xFF);
    for(var j = 0; j < 4; j++)
    {
    if(i * 8 + j * 6 > binarray.length * 32) str += b64pad;
    else str += tab.charAt((triplet >> 6*(3-j)) & 0x3F);
    }
    }
    return str;
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /*
    * Convert a string to an array of little-endian words
    * If chrsz is ASCII, characters >255 have their hi-byte silently ignored.
    */
    function str2binl(str)
    {
    var bin = Array();
    var mask = (1 << chrsz) - 1;
    for(var i = 0; i < str.length * chrsz; i += chrsz)
    bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (i%32);
    return bin;
    }

    위 js코드를 이용해서 파이썬에서 이용할 수 있도록 코드를 작성하였다

    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
    chrsz = 16
    b64pad = '='

    def str2binl(s): # s == "str"
    b_array = [] # 원본 js 코드의 bin
    i_max = chrsz * ((len(s)*chrsz-1) // chrsz)
    idx_max = (i_max >> 5) + 1
    for i in range(idx_max): # 원본 js에는 없는 루틴이지만 이 코드 없이 실행하면 파이썬에서는 index out of range 발생
    b_array.append(0) # 0 == or 연산에 대한 항등원
    mask = (1<<chrsz) - 1
    for i in range(0, len(s)*chrsz, chrsz):
    b_array[i>>5] |= (ord(s[i//chrsz]) & mask) << (i%32)
    return b_array


    def binl2b64(binarray):
    binarray_c = binarray[:] # deep copy
    tab = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
    result_str = '' # 원본 코드의 'str'
    # js에서 index 보다 큰 위치에 접근하면 0을 반환
    for i in range(0, len(binarray_c)*4, 3):
    try:
    op1 = (((binarray_c[i >> 2] >> 8 * ( i %4)) & 0xFF) << 16)
    except IndexError:
    op1 = 0
    try:
    op2 = (((binarray_c[i+1 >> 2] >> 8 * ((i+1)%4)) & 0xFF) << 8 )
    except IndexError:
    op2 = 0
    try:
    op3 = ((binarray_c[i+2 >> 2] >> 8 * ((i+2)%4)) & 0xFF)
    except IndexError:
    op3 = 0
    triplet = op1 | op2 | op3
    for j in range(4):
    if i * 8 + j * 6 > len(binarray_c)*32:
    result_str += b64pad
    else:
    result_str += tab[(triplet >> 6 * (3 - j)) & 0x3F]
    return result_str

    def b64_unicode(s):
    return binl2b64(str2binl(s))

    validate_form_no_challenge() 함수를 다시 보자

    1
    2
    3
    4
    5
    6
    7
    function validate_form_no_challenge( form )
    {
    form.encoded_pw.value = base64encode( form.password.value );
    form.encoded_pw_unicode.value = b64_unicode( form.password.value );
    form.password.value = "";
    return true;
    }

    password의 값을 가져와서 base64 인코딩 후 encoded_pw에 넣고, b64_unicode()를 호출해서 encoded_pw_unicode에 넣는다.

    따라서 아래와 같이 코드를 작성할 수 있다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    from base64 import b64encode
    import requests
    from encode import b64_unicode

    raw_pw = 'xxxxxxxxxx'

    encoded_pw = b64encode(raw_pw.encode()).decode()
    encoded_pw_unicode = b64_unicode(raw_pw)

    data = {
    'user_id': '2015xxxx',
    'password': '',
    'encoded_pw': encoded_pw,
    'encoded_pw_unicode': encoded_pw_unicode,
    }

    res = requests.post('.../webapps/login/', data=data)
    with open('test.html', 'w') as f:
    f.write(res.content.decode())

    test.html을 열어보면

    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
    <HTML dir='ltr'>

    <HEAD>
    <META HTTP-EQUIV="Pragma" CONTENT="no-cache">
    <META HTTP-EQUIV="Cache-Control" CONTENT="no-cache">
    <script language="Javascript">
    cookie_name = "cookies_enabled";
    document.cookie = cookie_name + "=yes";
    if (!document.cookie) {
    document.location.href = "/webapps/login/nocookies.jsp";
    }
    document.cookie = cookie_name + "yes;expires=Thu, 01-Jan-1970 00:00:01 GMT";
    </script>
    <SCRIPT language="Javascript">< !--
    document.location.replace('.../webapps/portal/frameset.jsp');
    //--></SCRIPT>
    </HEAD>

    <BODY BGCOLOR='#FFFFFF' LINK='#000000' ALINK='#000000'>
    <br>
    <br>
    <br>
    <br>
    <div style="text-align: center;">
    <hr width='350' height='5'>
    <br>
    <strong>You are being redirected to another page</strong>
    <p>
    <strong>Please Wait...</strong>
    <br>
    <br>
    <hr width='350' height='5'>
    <br>
    <A HREF='.../webapps/portal/frameset.jsp'>
    <strong>Click here to access the page to which you are being forwarded.</strong>
    </A>
    </div>
    </BODY>

    </HTML>

    로그인 후에는 쿠키를 유지하고 /webapps/portal/frameset.jsp로 이동해야한다.

    따라서 코드를 아래와 같이 작성하고

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    from base64 import b64encode
    import requests
    from encode import b64_unicode

    raw_pw = 'xxxxxxxxxx'

    encoded_pw = b64encode(raw_pw.encode()).decode()
    encoded_pw_unicode = b64_unicode(raw_pw)

    data = {
    'user_id': '2015xxxx',
    'encoded_pw': encoded_pw,
    'encoded_pw_unicode': encoded_pw_unicode,

    }

    res = requests.post('.../webapps/login/', data=data)
    res_ = requests.get('.../webapps/portal/frameset.jsp', cookies=res.cookies)
    with open('test.html', 'w') as f:
    f.write(res_.content.decode())

    실행하면

    성공이다.

    2018-12-02 추가: 교내 서버로 운영되던 서비스가 최근에 클라우드로 이전하면서 해당 코드로 동작하지 않을 수 있습니다. 여기에서 설명했던 일부 보안 문제는 이 과정에서 해결되었으니 다행입니다



    'Python > web scraping' 카테고리의 다른 글

    Docucentre-V C2263 작업기록 파싱기  (0) 2019.01.26

    댓글

Designed by Tistory.